From b608f2f552766e70c985d009de98484a80fcc448 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Wed, 9 Sep 2026 18:41:54 +0530 Subject: [PATCH 01/87] fix(user): append village ID into villageName so duplicate names are distinguishable Two different villages can legitimately share the same name (confirmed live: Nikshay village IDs 275135 and 275136 are both named "Sanapindapadar", under two different facilities in Boriguma block, Koraput). The mobile app's post-login village dropdown displays only villageName, so a user has no way to tell which entry is which - selecting the wrong one loads zero HH records for that village, since worklists filter by villageID, not by name. getUserDetail now appends the ID into each name segment, e.g. "Sanapindapadar,Sanapindapadar" + "275135,275136" becomes "Sanapindapadar (275135),Sanapindapadar (275136)". villageId and the response shape (two parallel comma-separated strings) are unchanged. Verified this requires zero mobile app changes: the village dropdown (ServiceLocationViewModel -> BindingUtils.setSpinnerItems) is a plain ArrayAdapter that displays villageName verbatim, and selection is by array position + villageId, not by matching name text. Co-Authored-By: Claude Sonnet 5 --- .../flw/service/impl/UserServiceImpl.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/main/java/com/iemr/flw/service/impl/UserServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/UserServiceImpl.java index 52d3eefb1..7537e5917 100644 --- a/src/main/java/com/iemr/flw/service/impl/UserServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/UserServiceImpl.java @@ -31,6 +31,19 @@ public UserServiceRoleDTO getUserDetail(Integer userId) { UserServiceRoleDTO userRole = userServiceRoleRepo.getUserRole(userId).get(0); userRole.setFacilityData(facilityDataService.buildFacilityData(userId, userRole.getRoleName())); + // Two different villages can legitimately share the same name (confirmed live: + // Nikshay village IDs 275135 and 275136 are both named "Sanapindapadar", under + // two different facilities in Boriguma block, Koraput). The mobile app's village + // dropdown displays villageName only, so a user has no way to tell which one + // they're picking - selecting the wrong one loads zero HH records for that + // village, since worklists filter by ID, not name. Append the ID into each name + // segment so duplicates are visually distinguishable. Response shape is + // unchanged - still two parallel comma-separated strings - so this needs zero + // mobile app changes: it just displays whatever text is in the name string + // (confirmed against ServiceLocationViewModel/BindingUtils.setSpinnerItems - + // plain ArrayAdapter, selection is by array position, not by name content). + userRole.setVillageName(appendVillageIds(userRole.getVillageId(), userRole.getVillageName())); + // Stop TB / Nikshay — additive only. This naturally returns nothing for // any user whose rows don't have NikshayTUID set, i.e. every non-Stop-TB // user. Fetched first so the district-by-block patch below can tell @@ -90,6 +103,32 @@ public UserServiceRoleDTO getUserDetail(Integer userId) { return userRole; } + // Zips the parallel villageId/villageName comma lists and rejoins each pair as + // "name (id)", e.g. "Sanapindapadar,Sanapindapadar" + "275135,275136" becomes + // "Sanapindapadar (275135),Sanapindapadar (275136)". Falls back to the original, + // unmodified villageName on any shape mismatch (null/blank, or unequal segment + // counts between the two lists) rather than risk emitting a malformed string. + private String appendVillageIds(String villageId, String villageName) { + if (villageId == null || villageId.isBlank() || villageName == null || villageName.isBlank()) { + return villageName; + } + String[] ids = villageId.split(",", -1); + String[] names = villageName.split(",", -1); + if (ids.length != names.length) { + logger.warn("villageId/villageName segment count mismatch ({} vs {}); leaving villageName unmodified", + ids.length, names.length); + return villageName; + } + StringBuilder result = new StringBuilder(); + for (int i = 0; i < names.length; i++) { + if (i > 0) { + result.append(","); + } + result.append(names[i].trim()).append(" (").append(ids[i].trim()).append(")"); + } + return result.toString(); + } + private void addCsvIds(String csv, Collection target) { if (csv == null || csv.isBlank()) { return; From 8a4360b9f5c619d9d1f7e303aa7558d74eeb74b4 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 11 Sep 2026 13:15:35 +0530 Subject: [PATCH 02/87] chore: bump version to 3.8.4 Co-Authored-By: Claude Sonnet 5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e92d98637..1d3e88724 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.8.3 + 3.8.4 war FLW-API From 911e2be1af9e009158e8139cc19a280a83317f27 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 15 Sep 2026 14:03:51 +0530 Subject: [PATCH 03/87] trace: add diagnostic logging to getBeneficiaryData for count-mismatch investigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds temporary [TRACE getBeneficiaryData] log lines to trace which caller/device is hitting the Stop TB worklist endpoint, how many pages/rows it gets back, and the household/non-household split actually sent to the client per call: - Controller: log real caller username (from JWT) + providerServiceMapID/ villageID/pageNo per request, instead of the previous useless default toString() of the request DTO. - Service: log totalCount/totalPage/addressIds per Stop TB page fetch. - Service: warn when getByAddressID() or getDetailsByRegID() return more than one row for a single address/beneficiary (both do .get(0) with no ORDER BY) — logs the distinct houseoldId values seen among duplicates, to confirm whether duplicate detail rows actually carry conflicting household links in production (not reproduced on the .198 demo DB, where duplicates found were exact clones with identical houseoldId). - Service: log a per-call summary of household-linked vs non-household counts in the response, for direct comparison against what each device's Home screen shows. Intended to be deployed to UAT StopTB and removed once the root cause of Nurse vs Registrar count mismatches (Achuthapuram, village 5821) is confirmed from real traffic. Co-Authored-By: Claude Sonnet 5 --- .../flw/controller/BeneficiaryController.java | 15 +++++- .../service/impl/BeneficiaryServiceImpl.java | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/flw/controller/BeneficiaryController.java b/src/main/java/com/iemr/flw/controller/BeneficiaryController.java index d439fbf5d..ab27eb36d 100644 --- a/src/main/java/com/iemr/flw/controller/BeneficiaryController.java +++ b/src/main/java/com/iemr/flw/controller/BeneficiaryController.java @@ -52,8 +52,19 @@ public String getBeneficiaryDataByAsha(@RequestBody GetBenRequestHandler request try { if (requestDTO != null) { - logger.info("request object with timestamp : " + new Timestamp(System.currentTimeMillis()) + " " - + requestDTO); + String callerUserName; + try { + callerUserName = jwtUtil.extractUsername(authorization); + } catch (Exception ex) { + callerUserName = "unknown"; + } + // TRACE (getBenData root-cause investigation): who called, for which village/page, + // so device-vs-device call counts can be compared from logs. Remove once resolved. + logger.info("[TRACE getBeneficiaryData] caller=" + callerUserName + + " providerServiceMapID=" + requestDTO.getProviderServiceMapID() + + " villageID=" + requestDTO.getVillageID() + + " pageNo=" + requestDTO.getPageNo() + + " timestamp=" + new Timestamp(System.currentTimeMillis())); String s = beneficiaryService.getBenData(requestDTO, authorization); if (s != null) response.setResponse(s); diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index 396c79a2d..e3076c36f 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -153,6 +153,13 @@ public String getBenData(GetBenRequestHandler request, String authorisation) thr List addressIds = beneficiaryRepo.getVillageWorklistAddressIds( request.getProviderServiceMapID(), request.getVillageID(), pageSize, offset); + // TRACE (getBenData root-cause investigation): per-page counts as seen server-side, + // so they can be compared against what each device shows on screen. Remove once resolved. + logger.info("[TRACE getBeneficiaryData] StopTB page result: providerServiceMapID=" + + request.getProviderServiceMapID() + " villageID=" + request.getVillageID() + + " pageNo=" + request.getPageNo() + " totalCount=" + totalCount + + " totalPage=" + totalPage + " addressIdsOnPage=" + addressIds.size()); + if (addressIds.isEmpty()) return null; // Was findAddressesByIds() - a single "IN :ids" JPQL query. Hit a reproducible @@ -237,6 +244,14 @@ private String getMappingsForAddressIDs(List addressLi // exception by-passing try { List mappingsForAddress = beneficiaryRepo.getByAddressID(a.getId()); + if (mappingsForAddress.size() > 1) { + // TRACE (getBenData root-cause investigation): duplicate mapping rows for one + // address — .get(0) below picks whichever comes back first, no ORDER BY. + // Remove once resolved. + logger.warn("[TRACE getBeneficiaryData] addressID=" + a.getId() + " has " + + mappingsForAddress.size() + " mapping rows (expected 1); picking first: benMapId=" + + mappingsForAddress.get(0).getBenMapId()); + } if(!mappingsForAddress.isEmpty()){ RMNCHMBeneficiarymapping m = mappingsForAddress.get(0); if (m != null) { @@ -273,6 +288,21 @@ private String getMappingsForAddressIDs(List addressLi if (m.getBenRegId() != null) { List detailsByRegID = beneficiaryRepo .getDetailsByRegID((m.getBenRegId()).longValue()); + if (detailsByRegID.size() > 1) { + // TRACE (getBenData root-cause investigation): duplicate detail rows for + // one beneficiary — .get(0) below picks whichever comes back first, no + // ORDER BY. Logging distinct houseoldId values seen: if they actually + // differ, this is the household/non-household flip-flop root cause; if + // they're all identical, this is a (separate) harmless-duplicate-insert + // issue and NOT the cause. Remove once resolved. + java.util.Set distinctHouseholdIds = detailsByRegID.stream() + .map(RMNCHBeneficiaryDetailsRmnch::getHouseoldId) + .collect(java.util.stream.Collectors.toSet()); + logger.warn("[TRACE getBeneficiaryData] BenRegId=" + m.getBenRegId() + " has " + + detailsByRegID.size() + " detail rows (expected 1); distinct houseoldId values=" + + distinctHouseholdIds + "; picking first: beneficiaryDetails_RmnchId=" + + detailsByRegID.get(0).getBeneficiaryDetails_RmnchId()); + } if(!detailsByRegID.isEmpty()){ benDetailsRMNCH_OBJ = detailsByRegID.get(0); } @@ -623,6 +653,23 @@ private String getMappingsForAddressIDs(List addressLi } } + // TRACE (getBenData root-cause investigation): household vs non-household split as + // actually sent to the client on this call, for direct comparison with what each + // device shows on screen. Remove once resolved. + long householdLinkedCount = resultList.stream() + .filter(r -> r.get("houseoldId") != null) + .count(); + String traceCaller; + try { + traceCaller = jwtUtil.extractUsername(authorisation); + } catch (Exception ex) { + traceCaller = "unknown"; + } + logger.info("[TRACE getBeneficiaryData] response summary: caller=" + traceCaller + + " addressesRequested=" + addressList.size() + " beneficiariesReturned=" + resultList.size() + + " householdLinked=" + householdLinkedCount + + " nonHousehold=" + (resultList.size() - householdLinkedCount)); + Map response = new HashMap<>(); response.put("data", resultList); response.put("pageSize", Integer.parseInt(door_to_door_page_size)); From 139c3cd44dd235aaf5e3b16039f434961311406c Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 15 Sep 2026 15:13:07 +0530 Subject: [PATCH 04/87] fix: order duplicate-row lookups in getBeneficiaryData so .get(0) is deterministic and correct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BenAddressId (i_beneficiarymapping.getByAddressID) and BenRegId (i_beneficiarydetails_rmnch.getDetailsByRegID) can both have more than one matching row, but the queries had no ORDER BY and the calling code in BeneficiaryServiceImpl just takes .get(0) — whichever row MySQL happened to return first, not necessarily the correct one. Confirmed live against the UAT StopTB database (Achuthapuram, village 5821): 36+ address IDs are shared between an old test-account registration and a real one, and some beneficiaries have duplicate detail rows where only the newer one has the household link recorded. Picking an arbitrary row instead of the latest one is why the Nurse and Registrar apps showed different Household/Non-Household counts for the same village. - getByAddressID: ORDER BY benMapId DESC, same "latest wins" convention already used in findByBenRegIdFromMapping (a5e57349). - getDetailsByRegID: ORDER BY beneficiaryDetails_RmnchId DESC. Verified locally against the real UAT DB: household/non-household split now matches the true numbers (computed from i_ben_flow_outreach, the actual source getBeneficiaryData's village worklist is built from) within a 1-record margin from an unrelated, pre-existing "mapping not available" skip. Does not touch the underlying duplicate rows themselves — those still need a data cleanup decision (which record to keep) separately from this code fix. Co-Authored-By: Claude Sonnet 5 --- .../com/iemr/flw/repo/identity/BeneficiaryRepo.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java b/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java index a6ee9700c..a1b0b9333 100644 --- a/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java +++ b/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java @@ -47,7 +47,11 @@ Page getBenDataWithinDates( @Query(value = " SELECT t FROM RMNCHMBeneficiaryaddress t WHERE t.createdBy = :userName ") Page getBenDataByUser(@Param("userName") String userName, Pageable pageable); - @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benAddressId = :addressID") + // BenAddressId can have more than one mapping row (confirmed live: address-ID collisions + // between old/test registrations and real ones). Order by PK desc, same "latest wins" + // convention already used for findByBenRegIdFromMapping, so callers picking .get(0) get + // the newest mapping deterministically instead of whichever row the DB returns first. + @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benAddressId = :addressID ORDER BY t.benMapId DESC") List getByAddressID(@Param("addressID") BigInteger addressID); @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benRegId = :BenRegId") @@ -80,7 +84,11 @@ Page getBenDataWithinDates( @Query(value = " SELECT t.beneficiaryID FROM RMNCHMBeneficiaryregidmapping t WHERE t.benRegId = :benRegID ") BigInteger getBenIdFromRegID(@Param("benRegID") Long benRegID); - @Query(value = " SELECT t FROM RMNCHBeneficiaryDetailsRmnch t WHERE t.BenRegId =:benRegID ") + // BenRegId has no unique constraint on i_beneficiarydetails_rmnch — a beneficiary can have + // more than one row (confirmed live: duplicates, some created well after the original with + // a household link the older row never got). Order by PK desc so callers picking .get(0) + // get the latest row, not whichever one the DB happens to return first. + @Query(value = " SELECT t FROM RMNCHBeneficiaryDetailsRmnch t WHERE t.BenRegId =:benRegID ORDER BY t.beneficiaryDetails_RmnchId DESC ") List getDetailsByRegID(@Param("benRegID") Long benRegID); @Query(value = " SELECT t FROM RMNCHBornBirthDetails t WHERE t.BenRegId =:benRegID ") From 870068a56a9536086cb94f51f64bb1f58d112417 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 15 Sep 2026 16:27:08 +0530 Subject: [PATCH 05/87] Revert "trace: add diagnostic logging to getBeneficiaryData for count-mismatch investigation" This reverts commit 911e2be1af9e009158e8139cc19a280a83317f27. --- .../flw/controller/BeneficiaryController.java | 15 +----- .../service/impl/BeneficiaryServiceImpl.java | 47 ------------------- 2 files changed, 2 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/iemr/flw/controller/BeneficiaryController.java b/src/main/java/com/iemr/flw/controller/BeneficiaryController.java index ab27eb36d..d439fbf5d 100644 --- a/src/main/java/com/iemr/flw/controller/BeneficiaryController.java +++ b/src/main/java/com/iemr/flw/controller/BeneficiaryController.java @@ -52,19 +52,8 @@ public String getBeneficiaryDataByAsha(@RequestBody GetBenRequestHandler request try { if (requestDTO != null) { - String callerUserName; - try { - callerUserName = jwtUtil.extractUsername(authorization); - } catch (Exception ex) { - callerUserName = "unknown"; - } - // TRACE (getBenData root-cause investigation): who called, for which village/page, - // so device-vs-device call counts can be compared from logs. Remove once resolved. - logger.info("[TRACE getBeneficiaryData] caller=" + callerUserName - + " providerServiceMapID=" + requestDTO.getProviderServiceMapID() - + " villageID=" + requestDTO.getVillageID() - + " pageNo=" + requestDTO.getPageNo() - + " timestamp=" + new Timestamp(System.currentTimeMillis())); + logger.info("request object with timestamp : " + new Timestamp(System.currentTimeMillis()) + " " + + requestDTO); String s = beneficiaryService.getBenData(requestDTO, authorization); if (s != null) response.setResponse(s); diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index e3076c36f..396c79a2d 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -153,13 +153,6 @@ public String getBenData(GetBenRequestHandler request, String authorisation) thr List addressIds = beneficiaryRepo.getVillageWorklistAddressIds( request.getProviderServiceMapID(), request.getVillageID(), pageSize, offset); - // TRACE (getBenData root-cause investigation): per-page counts as seen server-side, - // so they can be compared against what each device shows on screen. Remove once resolved. - logger.info("[TRACE getBeneficiaryData] StopTB page result: providerServiceMapID=" - + request.getProviderServiceMapID() + " villageID=" + request.getVillageID() - + " pageNo=" + request.getPageNo() + " totalCount=" + totalCount - + " totalPage=" + totalPage + " addressIdsOnPage=" + addressIds.size()); - if (addressIds.isEmpty()) return null; // Was findAddressesByIds() - a single "IN :ids" JPQL query. Hit a reproducible @@ -244,14 +237,6 @@ private String getMappingsForAddressIDs(List addressLi // exception by-passing try { List mappingsForAddress = beneficiaryRepo.getByAddressID(a.getId()); - if (mappingsForAddress.size() > 1) { - // TRACE (getBenData root-cause investigation): duplicate mapping rows for one - // address — .get(0) below picks whichever comes back first, no ORDER BY. - // Remove once resolved. - logger.warn("[TRACE getBeneficiaryData] addressID=" + a.getId() + " has " - + mappingsForAddress.size() + " mapping rows (expected 1); picking first: benMapId=" - + mappingsForAddress.get(0).getBenMapId()); - } if(!mappingsForAddress.isEmpty()){ RMNCHMBeneficiarymapping m = mappingsForAddress.get(0); if (m != null) { @@ -288,21 +273,6 @@ private String getMappingsForAddressIDs(List addressLi if (m.getBenRegId() != null) { List detailsByRegID = beneficiaryRepo .getDetailsByRegID((m.getBenRegId()).longValue()); - if (detailsByRegID.size() > 1) { - // TRACE (getBenData root-cause investigation): duplicate detail rows for - // one beneficiary — .get(0) below picks whichever comes back first, no - // ORDER BY. Logging distinct houseoldId values seen: if they actually - // differ, this is the household/non-household flip-flop root cause; if - // they're all identical, this is a (separate) harmless-duplicate-insert - // issue and NOT the cause. Remove once resolved. - java.util.Set distinctHouseholdIds = detailsByRegID.stream() - .map(RMNCHBeneficiaryDetailsRmnch::getHouseoldId) - .collect(java.util.stream.Collectors.toSet()); - logger.warn("[TRACE getBeneficiaryData] BenRegId=" + m.getBenRegId() + " has " - + detailsByRegID.size() + " detail rows (expected 1); distinct houseoldId values=" - + distinctHouseholdIds + "; picking first: beneficiaryDetails_RmnchId=" - + detailsByRegID.get(0).getBeneficiaryDetails_RmnchId()); - } if(!detailsByRegID.isEmpty()){ benDetailsRMNCH_OBJ = detailsByRegID.get(0); } @@ -653,23 +623,6 @@ private String getMappingsForAddressIDs(List addressLi } } - // TRACE (getBenData root-cause investigation): household vs non-household split as - // actually sent to the client on this call, for direct comparison with what each - // device shows on screen. Remove once resolved. - long householdLinkedCount = resultList.stream() - .filter(r -> r.get("houseoldId") != null) - .count(); - String traceCaller; - try { - traceCaller = jwtUtil.extractUsername(authorisation); - } catch (Exception ex) { - traceCaller = "unknown"; - } - logger.info("[TRACE getBeneficiaryData] response summary: caller=" + traceCaller - + " addressesRequested=" + addressList.size() + " beneficiariesReturned=" + resultList.size() - + " householdLinked=" + householdLinkedCount - + " nonHousehold=" + (resultList.size() - householdLinkedCount)); - Map response = new HashMap<>(); response.put("data", resultList); response.put("pageSize", Integer.parseInt(door_to_door_page_size)); From 90e1f0a2e228136493bde27382195e9f4b64dce3 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 15 Sep 2026 16:37:55 +0530 Subject: [PATCH 06/87] Dropping duplicate columns in table tb_stoptb_general_examination --- .../domain/iemr/StopTBGeneralExamination.java | 17 ++++------------- .../flw/service/impl/StopTBServiceImpl.java | 17 +++++++++-------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java b/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java index 9972d916d..59b271e1a 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java +++ b/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java @@ -72,19 +72,10 @@ public class StopTBGeneralExamination { @Column(name = "clubbing") private String clubbing; - // JSON arrays serialised from mobile - @Column(name = "key_population_risk_factor_ids", columnDefinition = "TEXT") - private String keyPopulationRiskFactorIds; - - @Column(name = "key_population_risk_factors", columnDefinition = "TEXT") - private String keyPopulationRiskFactors; - - @Column(name = "hiv_status_id") - private Integer hivStatusId; - - // "Positive" | "Reactive" | "Negative" | "Unknown" - @Column(name = "hiv_status") - private String hivStatus; + // key_population_risk_factor_ids/_factors and hiv_status_id/hiv_status were removed from + // this table — TB Screening (TBScreening) is the single source of truth for these fields + // (see StopTBServiceImpl#examToMap, and the AMRIT-DB migration that backfilled tb_screening + // from this table's historical data before dropping the columns here). @Column(name = "referral_to_hwc_needed_id") private Integer referralToHWCNeededId; diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index cdeaf11af..f6e508b90 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -134,10 +134,8 @@ public List> saveGeneralExamination(List exam.setCyanosis(getString(data, "cyanosis")); exam.setClubbingId(getInt(data, "clubbingId")); exam.setClubbing(getString(data, "clubbing")); - exam.setKeyPopulationRiskFactorIds(toJsonString(data.get("keyPopulationRiskFactorIds"))); - exam.setKeyPopulationRiskFactors(toJsonString(data.get("keyPopulationRiskFactors"))); - exam.setHivStatusId(getInt(data, "hivStatusId")); - exam.setHivStatus(getString(data, "hivStatus")); + // hivStatus*/keyPopulationRiskFactor* are intentionally not set here — TB Screening + // is the single source of truth for these fields (see examToMap). exam.setCreatedBy(getString(data, "createdBy")); exam.setModifiedBy(getString(data, "createdBy")); exam.setDeleted(false); @@ -203,10 +201,13 @@ private Map examToMap(StopTBGeneralExamination e) { m.put("cyanosis", e.getCyanosis()); m.put("clubbingId", e.getClubbingId()); m.put("clubbing", e.getClubbing()); - m.put("keyPopulationRiskFactorIds", e.getKeyPopulationRiskFactorIds()); - m.put("keyPopulationRiskFactors", e.getKeyPopulationRiskFactors()); - m.put("hivStatusId", e.getHivStatusId()); - m.put("hivStatus", e.getHivStatus()); + // hivStatus*/keyPopulationRiskFactor* are sourced from TB Screening (the source of + // truth for these fields) rather than from this entity's own (deprecated) columns. + TBScreening screening = tbScreeningRepo.findByBenRegIDAndVisitCode(e.getBeneficiaryRegID(), e.getVisitCode()); + m.put("keyPopulationRiskFactorIds", screening != null ? screening.getKeyPopulationRiskFactorIds() : null); + m.put("keyPopulationRiskFactors", screening != null ? screening.getKeyPopulationRiskFactors() : null); + m.put("hivStatusId", screening != null ? screening.getHivStatusId() : null); + m.put("hivStatus", screening != null ? screening.getHivStatus() : null); m.put("referralToHWCNeeded", e.getReferralToHWCNeeded()); m.put("createdBy", e.getCreatedBy()); m.put("createdDate", e.getCreatedDate()); From 5e1a670d828f841a12543da64ed70a34bbb89d7a Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 22 Sep 2026 10:37:07 +0530 Subject: [PATCH 07/87] Adding migration applier and removing column migration file --- .../domain/iemr/DynamicFormMigrationLog.java | 53 +++ .../iemr/flw/domain/iemr/QuestionOption.java | 5 + .../iemr/flw/domain/iemr/SectionQuestion.java | 5 + .../com/iemr/flw/dto/iemr/FormSectionDTO.java | 2 +- .../iemr/flw/dto/iemr/QuestionOptionDTO.java | 3 +- .../iemr/flw/dto/iemr/SectionQuestionDTO.java | 2 +- .../iemr/DynamicFormMigrationLogRepo.java | 34 ++ .../iemr/flw/repo/iemr/FormSectionRepo.java | 13 + .../flw/repo/iemr/OptionConditionRepo.java | 8 + .../flw/repo/iemr/QuestionOptionRepo.java | 17 +- .../flw/repo/iemr/QuestionValidationRepo.java | 6 + .../flw/repo/iemr/SectionQuestionRepo.java | 20 +- .../DynamicFormMigrationApplier.java | 49 +++ .../migration/DynamicFormMigrationRunner.java | 73 ++++ .../migration/FormStructureMigration.java | 78 +++++ .../migration/V001_ReplaceTfuAdrOptions.java | 104 ++++++ .../DynamicFormReconciliationService.java | 101 ++++++ .../DynamicFormReconciliationServiceImpl.java | 324 ++++++++++++++++++ 18 files changed, 891 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java create mode 100644 src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java diff --git a/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java new file mode 100644 index 000000000..1ff0158d7 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java @@ -0,0 +1,53 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.sql.Timestamp; + +/** + * One row per applied {@code com.iemr.flw.seeder.migration.FormStructureMigration}, keyed by its + * stable migrationId — lets {@code DynamicFormMigrationRunner} skip a migration it has already run. + */ +@Entity +@Table(name = "t_dynamic_form_migration_log", schema = "db_iemr") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DynamicFormMigrationLog { + + @Id + @Column(name = "migrationId", length = 50) + private String migrationId; + + @CreationTimestamp + @Column(name = "appliedAt", nullable = false, updatable = false) + private Timestamp appliedAt; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index 124ea6faf..645fa0d1b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -76,6 +76,11 @@ public class QuestionOption { @Column(name = "displayOrder", nullable = false) private Integer displayOrder; + /** Soft-delete flag: false means removed from its question. Never hard-deleted — historical + * QuestionResponse rows reference optionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 1aa324cb5..a50418d0e 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -98,6 +98,11 @@ public class SectionQuestion { @Column(name = "containsPii", nullable = false) private Boolean containsPii = false; + /** Soft-delete flag: false means unlinked from its section. Never hard-deleted — historical + * QuestionResponse rows reference questionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java index 6bb107958..0d950d2ad 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java @@ -55,7 +55,7 @@ public class FormSectionDTO { private Boolean isRequired = true; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at that position (existing siblings shift up). */ private Integer displayOrder; private Boolean hasSubmitButton = false; diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java index 6682404a0..d1547c3b9 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java @@ -23,7 +23,6 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -51,7 +50,7 @@ public class QuestionOptionDTO { private String optionValueHindi; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at that position (existing siblings shift up). */ private Integer displayOrder; @Valid diff --git a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java index 11a814303..2198ccc47 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java @@ -56,7 +56,7 @@ public class SectionQuestionDTO { private Boolean isMandatory = true; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at (or move to) that position (existing siblings shift). */ private Integer displayOrder; private Integer maxLength; diff --git a/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java new file mode 100644 index 000000000..9f993076b --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java @@ -0,0 +1,34 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Repository for applied form-structure migration records. + * Id type is the migrationId String itself — existsById/save need no custom methods. + */ +@Repository +public interface DynamicFormMigrationLogRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java index 9f135959b..fa6acdf19 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java @@ -24,12 +24,14 @@ import com.iemr.flw.domain.iemr.FormSection; import com.iemr.flw.masterEnum.SectionPhase; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for form sections. @@ -40,6 +42,17 @@ public interface FormSectionRepo extends JpaRepository { List findByFormVersion_VersionIdOrderByDisplayOrderAsc(Long versionId); + Optional findByFormVersion_VersionIdAndSectionUuid(Long versionId, String sectionUuid); + + Optional findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(Long versionId); + + /** Shifts displayOrder by delta for every section in [from, to] within a version — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE FormSection s SET s.displayOrder = s.displayOrder + :delta " + + "WHERE s.formVersion.versionId = :versionId AND s.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("versionId") Long versionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + @Query("SELECT fs.formVersion.versionId, COUNT(fs) FROM FormSection fs " + "WHERE fs.formVersion.versionId IN :versionIds " + "AND fs.sectionPhase = :sectionPhase " + diff --git a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java index 633418894..c82040ea1 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java @@ -29,6 +29,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for option conditions. @@ -38,6 +39,13 @@ public interface OptionConditionRepo extends JpaRepository findByQuestionOption_OptionId(Long optionId); + /** No surrogate natural key on conditions — matched by (actionType, target) within an option. */ + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + Long optionId, String actionType, String targetQuestionUuid); + + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + Long optionId, String actionType, String targetSectionUuid); + @Query("SELECT oc.targetQuestion.questionId FROM OptionCondition oc " + "WHERE oc.questionOption.sectionQuestion.formSection.formVersion.versionId = :versionId " + "AND oc.targetQuestion IS NOT NULL") diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java index f75120551..613abb266 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.QuestionOption; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question options. @@ -38,11 +40,24 @@ public interface QuestionOptionRepo extends JpaRepository List findBySectionQuestion_QuestionIdOrderByDisplayOrderAsc(Long questionId); + Optional findBySectionQuestion_QuestionIdAndOptionValue(Long questionId, String optionValue); + + Optional findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(Long questionId); + + /** Shifts displayOrder by delta for every option in [from, to] within a question — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE QuestionOption o SET o.displayOrder = o.displayOrder + :delta " + + "WHERE o.sectionQuestion.questionId = :questionId AND o.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("questionId") Long questionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all options for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. + * Excludes removed (isActive=false) options — used only by read paths; reconciliation matching + * uses the natural-key finder above, which must see inactive rows too. */ @Query("SELECT o FROM QuestionOption o JOIN FETCH o.sectionQuestion " - + "WHERE o.sectionQuestion.questionId IN :questionIds ORDER BY o.displayOrder ASC") + + "WHERE o.sectionQuestion.questionId IN :questionIds AND o.isActive = true ORDER BY o.displayOrder ASC") List findByQuestionIdsOrderByDisplayOrderAsc(@Param("questionIds") Collection questionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java index d1cf41876..ad8be0fa7 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java @@ -22,6 +22,7 @@ package com.iemr.flw.repo.iemr; import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.masterEnum.ValidationType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -29,6 +30,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question validation rules. @@ -38,6 +40,10 @@ public interface QuestionValidationRepo extends JpaRepository findBySectionQuestion_QuestionId(Long questionId); + /** No surrogate natural key on validations — matched by the (type, param) content tuple within a question. */ + Optional findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + Long questionId, ValidationType validationType, String validationParam); + /** * Loads all validations for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. diff --git a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java index 6a117e968..ae2a6744c 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.SectionQuestion; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for section questions. @@ -38,11 +40,27 @@ public interface SectionQuestionRepo extends JpaRepository findByFormSection_SectionIdOrderByDisplayOrderAsc(Long sectionId); + Optional findByFormSection_SectionIdAndQuestionUuid(Long sectionId, String questionUuid); + + /** Version-scoped lookup — a condition's target question may live in a different section of the same version. */ + Optional findByFormSection_FormVersion_VersionIdAndQuestionUuid(Long versionId, String questionUuid); + + Optional findTopByFormSection_SectionIdOrderByDisplayOrderDesc(Long sectionId); + + /** Shifts displayOrder by delta for every question in [from, to] within a section — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE SectionQuestion q SET q.displayOrder = q.displayOrder + :delta " + + "WHERE q.formSection.sectionId = :sectionId AND q.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("sectionId") Long sectionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all questions for a set of sections in one query. * JOIN FETCH ensures formSection is hydrated so callers can group by sectionId without extra queries. + * Excludes unlinked (isActive=false) questions — used only by read paths; reconciliation matching + * uses the natural-key finders above, which must see inactive rows too. */ @Query("SELECT q FROM SectionQuestion q JOIN FETCH q.formSection " - + "WHERE q.formSection.sectionId IN :sectionIds ORDER BY q.displayOrder ASC") + + "WHERE q.formSection.sectionId IN :sectionIds AND q.isActive = true ORDER BY q.displayOrder ASC") List findBySectionIdsOrderByDisplayOrderAsc(@Param("sectionIds") Collection sectionIds); } diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java new file mode 100644 index 000000000..ae4de9c6e --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Applies one {@link FormStructureMigration} and records it as applied, as a single atomic unit. + * Kept as its own bean (rather than a method on {@link DynamicFormMigrationRunner}) so the + * {@code @Transactional} proxy is actually honored — a runner calling this method on itself would + * bypass Spring AOP's self-invocation limitation. + */ +@Service +@RequiredArgsConstructor +public class DynamicFormMigrationApplier { + + private final DynamicFormReconciliationService reconciliationService; + private final DynamicFormMigrationLogRepo migrationLogRepo; + + @Transactional + public void apply(FormStructureMigration migration) { + migration.apply(reconciliationService); + migrationLogRepo.save(new DynamicFormMigrationLog(migration.migrationId(), null)); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java new file mode 100644 index 000000000..2071b61ed --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java @@ -0,0 +1,73 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; + +/** + * Applies every {@link FormStructureMigration} bean, in {@link FormStructureMigration#migrationId()} + * order, skipping ones already recorded in {@code t_dynamic_form_migration_log}. + * + * Runs as an {@link ApplicationRunner} rather than another {@code @PostConstruct} specifically so it + * always runs after the 6 form seeders: Spring Boot invokes ApplicationRunners only once the whole + * application context has finished refreshing, which is strictly after every bean's + * {@code @PostConstruct} (the seeders included) has already completed — no {@code @Order} + * coordination needed between the two mechanisms. + * + * A migration that throws propagates out of this loop and aborts startup — a broken migration + * should fail loudly, not be silently skipped, and later migrations shouldn't apply out of order + * behind a failed one. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DynamicFormMigrationRunner implements ApplicationRunner { + + private final List migrations; + private final DynamicFormMigrationLogRepo migrationLogRepo; + private final DynamicFormMigrationApplier applier; + + @Override + public void run(ApplicationArguments args) { + migrations.stream() + .sorted(Comparator.comparing(FormStructureMigration::migrationId)) + .forEach(this::applyIfNeeded); + } + + private void applyIfNeeded(FormStructureMigration migration) { + String id = migration.migrationId(); + if (migrationLogRepo.existsById(id)) { + log.info("Form structure migration '{}' already applied — skipping.", id); + return; + } + applier.apply(migration); + log.info("Form structure migration '{}' applied.", id); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java new file mode 100644 index 000000000..969bb04f3 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java @@ -0,0 +1,78 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.service.DynamicFormReconciliationService; + +/** + * One incremental, one-off structural change to a form that has already been created by one of the + * {@code com.iemr.flw.seeder} classes — e.g. "add this new question to that existing section", or + * "add this new option to that existing question". {@code DynamicFormMigrationRunner} discovers + * every Spring bean implementing this interface, sorts them by {@link #migrationId()}, and applies + * each exactly once, recording it in {@code t_dynamic_form_migration_log} so it is skipped on + * every later restart. A migration should never be edited after it has shipped — write a new one + * instead — since the log only records "has this id run", not what it actually did. + * + * Naming convention: {@code V_ShortDescription}, e.g. {@code V001_AddSideEffectsQuestion}, + * zero-padded so lexicographic sort matches numeric order. Give each class a javadoc stating the + * business reason for the change — that, plus the class itself, is the permanent changelog. + * + * Example: + *
{@code
+ * @Component
+ * public class V001_AddSideEffectsQuestionToTbCounselling implements FormStructureMigration {
+ *
+ *     @Override
+ *     public String migrationId() {
+ *         return "V001";
+ *     }
+ *
+ *     @Override
+ *     public void apply(DynamicFormReconciliationService svc) {
+ *         FormVersion version = svc.latestVersion("TB_COUNSELLING")
+ *                 .orElseThrow(() -> new IllegalStateException(
+ *                         "TB_COUNSELLING not found — must run after TbCounsellingFormSeeder"));
+ *         FormSection section = svc.section(version, "TB_SEC_B")
+ *                 .orElseThrow(() -> new IllegalStateException("Section TB_SEC_B not found"));
+ *
+ *         SectionQuestionDTO question = new SectionQuestionDTO();
+ *         question.setQuestionUuid("TB_B_Q7");
+ *         question.setQuestionText("Side effects reported?");
+ *         question.setQuestionType(QuestionType.RADIO);
+ *         question.setOptions(List.of(yesOption, noOption));
+ *         svc.ensureQuestion(section, question);
+ *     }
+ * }
+ * }
+ */ +public interface FormStructureMigration { + + /** Stable id, e.g. "V001" — the migration-log primary key and the sort key for apply order. Never change it once shipped. */ + String migrationId(); + + /** + * Applies this migration's change(s) via the reconciliation service's ensureX methods. + * Should remain safe to re-run (ensureX no-ops on rows that already exist) even though the + * runner normally calls this only once per migrationId, per the tracking log. + */ + void apply(DynamicFormReconciliationService reconciliationService); +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java new file mode 100644 index 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/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/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); + } +} From fb8bc1a381d4c83178c19cb128e8c18f1dcecf10 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Tue, 22 Sep 2026 15:16:08 +0530 Subject: [PATCH 08/87] feat: add four new columns to TB screening form --- .../com/iemr/flw/domain/iemr/TBScreening.java | 18 ++++++++++++++++++ .../flw/service/impl/StopTBServiceImpl.java | 13 +++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/main/java/com/iemr/flw/domain/iemr/TBScreening.java b/src/main/java/com/iemr/flw/domain/iemr/TBScreening.java index 06e36309c..4af3c078b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/TBScreening.java +++ b/src/main/java/com/iemr/flw/domain/iemr/TBScreening.java @@ -194,4 +194,22 @@ public class TBScreening { @Column(name = "createdDate") private java.sql.Timestamp createdDate; + + @Column(name = "chest_pain") + private Boolean chestPain; + + @Column(name = "shortness_of_breath") + private Boolean shortnessOfBreath; + + @Column(name = "fatigue") + private Boolean fatigue; + + @Column(name = "failure_to_gain_weight") + private Boolean failureToGainWeight; + + @Column(name = "decreased_activity_or_playfulness") + private Boolean decreasedActivityOrPlayfulness; + + @Column(name = "others") + private Boolean others; } 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 cdeaf11af..c95364ee9 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -254,6 +254,13 @@ public List> saveNurseTBScreening(List> screening.setKeyPopulationRiskFactors(toJsonString(data.get("keyPopulationRiskFactors"))); screening.setHivStatusId(getInt(data, "hivStatusId")); screening.setHivStatus(getString(data, "hivStatus")); + screening.setChestPain(getBool(data, "chestPain")); + screening.setShortnessOfBreath(getBool(data, "shortnessOfBreath")); + screening.setFatigue(getBool(data, "fatigue")); + screening.setFailureToGainWeight(getBool(data, "failureToGainWeightInChildren")); + screening.setDecreasedActivityOrPlayfulness(getBool(data, "decreasedActivityOrPlayfulnessInChildren")); + screening.setOthers(getBool(data, "otherSymptoms")); + String symptomaticInput = getString(data, "symptomatic"); if ("Yes".equalsIgnoreCase(symptomaticInput)) { screening.setSympotomatic(null); @@ -349,6 +356,12 @@ private Map screeningToMap(TBScreening s) { m.put("visitDate", s.getVisitDate()); m.put("updateDate", s.getLastModDate()); m.put("updatedBy", s.getModifiedBy()); + m.put("chestPain", s.getChestPain()); + m.put("shortnessOfBreath", s.getShortnessOfBreath()); + m.put("fatigue", s.getFatigue()); + m.put("failureToGainWeightInChildren", s.getFailureToGainWeight()); + m.put("decreasedActivityOrPlayfulnessInChildren", s.getDecreasedActivityOrPlayfulness()); + m.put("otherSymptoms", s.getOthers()); return m; } From 35e62f8a050932e6169acdcfe42f7c06fb935b63 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 08:27:23 +0530 Subject: [PATCH 09/87] feat: add dynamic form migrator and contact tracing migration --- .../com/iemr/flw/domain/iemr/FormSection.java | 5 + .../com/iemr/flw/domain/iemr/FormVersion.java | 5 + .../iemr/flw/domain/iemr/OptionCondition.java | 4 + .../flw/domain/iemr/QuestionValidation.java | 4 + .../domain/iemr/StopTBGeneralExamination.java | 17 +- .../com/iemr/flw/dto/iemr/FormSectionDTO.java | 2 +- .../iemr/flw/dto/iemr/QuestionOptionDTO.java | 3 +- .../iemr/flw/dto/iemr/SectionQuestionDTO.java | 2 +- .../iemr/flw/masterEnum/ValidationType.java | 5 +- .../flw/repo/identity/BeneficiaryRepo.java | 6 +- .../iemr/flw/repo/iemr/FormSectionRepo.java | 3 + .../flw/repo/iemr/OptionConditionRepo.java | 4 +- .../flw/repo/iemr/QuestionValidationRepo.java | 4 +- .../V002_RenameNoOfContactsQuestion.java | 83 +++++++++ ..._ChangeExposureSettingToDropdownMulti.java | 76 ++++++++ .../V004_ChangeOccupationToDropdown.java | 76 ++++++++ .../V005_AddAreaOfSharedSpaceQuestion.java | 115 ++++++++++++ .../V006_PerRelationshipTypeOfSpace.java | 172 ++++++++++++++++++ .../V007_AddRelationshipHoursQuestions.java | 129 +++++++++++++ ...008_ChangeRelationshipToDropdownMulti.java | 77 ++++++++ .../DynamicFormDefinitionServiceImpl.java | 2 +- .../flw/service/impl/StopTBServiceImpl.java | 6 +- 22 files changed, 786 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java index 28f50859b..b645c9742 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java @@ -90,6 +90,11 @@ public class FormSection { @Column(name = "isEditable", nullable = false) private Boolean isEditable = false; + /** Soft-delete flag: false means removed from its version. Never hard-deleted — historical + * section responses reference it. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java index 5902aa218..62a5f646b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java @@ -71,6 +71,11 @@ public class FormVersion { @Column(name = "isLatest", nullable = false) private Boolean isLatest = true; + /** Soft-delete flag: false means the version is retired. Never hard-deleted — historical + * form responses reference their version. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "createdAt", nullable = false, updatable = false) private Timestamp createdAt; diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index 50af2b382..fbb12ce74 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -74,6 +74,10 @@ public class OptionCondition { @ToString.Exclude private FormSection targetSection; + /** Soft-delete flag: false means removed from its option. Never hard-deleted. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java index b7d41dc16..ebfe9ac33 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java @@ -72,6 +72,10 @@ public class QuestionValidation { @Column(name = "errorMessage", nullable = false, length = 500) private String errorMessage; + /** Soft-delete flag: false means removed from its question. Never hard-deleted. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java b/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java index 59b271e1a..9972d916d 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java +++ b/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java @@ -72,10 +72,19 @@ public class StopTBGeneralExamination { @Column(name = "clubbing") private String clubbing; - // key_population_risk_factor_ids/_factors and hiv_status_id/hiv_status were removed from - // this table — TB Screening (TBScreening) is the single source of truth for these fields - // (see StopTBServiceImpl#examToMap, and the AMRIT-DB migration that backfilled tb_screening - // from this table's historical data before dropping the columns here). + // JSON arrays serialised from mobile + @Column(name = "key_population_risk_factor_ids", columnDefinition = "TEXT") + private String keyPopulationRiskFactorIds; + + @Column(name = "key_population_risk_factors", columnDefinition = "TEXT") + private String keyPopulationRiskFactors; + + @Column(name = "hiv_status_id") + private Integer hivStatusId; + + // "Positive" | "Reactive" | "Negative" | "Unknown" + @Column(name = "hiv_status") + private String hivStatus; @Column(name = "referral_to_hwc_needed_id") private Integer referralToHWCNeededId; diff --git a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java index 0d950d2ad..6bb107958 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java @@ -55,7 +55,7 @@ public class FormSectionDTO { private Boolean isRequired = true; - /** Omit to auto-append after existing siblings; set explicitly to insert at that position (existing siblings shift up). */ + @NotNull(message = "displayOrder is required") private Integer displayOrder; private Boolean hasSubmitButton = false; diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java index d1547c3b9..6682404a0 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java @@ -23,6 +23,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -50,7 +51,7 @@ public class QuestionOptionDTO { private String optionValueHindi; - /** Omit to auto-append after existing siblings; set explicitly to insert at that position (existing siblings shift up). */ + @NotNull(message = "displayOrder is required") private Integer displayOrder; @Valid diff --git a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java index 2198ccc47..11a814303 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java @@ -56,7 +56,7 @@ public class SectionQuestionDTO { private Boolean isMandatory = true; - /** Omit to auto-append after existing siblings; set explicitly to insert at (or move to) that position (existing siblings shift). */ + @NotNull(message = "displayOrder is required") private Integer displayOrder; private Integer maxLength; diff --git a/src/main/java/com/iemr/flw/masterEnum/ValidationType.java b/src/main/java/com/iemr/flw/masterEnum/ValidationType.java index 931e24699..ed7891588 100644 --- a/src/main/java/com/iemr/flw/masterEnum/ValidationType.java +++ b/src/main/java/com/iemr/flw/masterEnum/ValidationType.java @@ -38,5 +38,8 @@ public enum ValidationType { REGEX, /** Field becomes mandatory when another field equals a specific value. * validationParam format: "QUESTION_UUID=OPTION_VALUE" e.g. "Q-GENDER=FEMALE". */ - MANDATORY_IF + MANDATORY_IF, + /** Display unit for a numeric answer. validationParam = unit label e.g. "sq. m.". + * Informational only — not a rule the answer is checked against. */ + UNIT } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java b/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java index a6ee9700c..6362a1403 100644 --- a/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java +++ b/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java @@ -47,7 +47,11 @@ Page getBenDataWithinDates( @Query(value = " SELECT t FROM RMNCHMBeneficiaryaddress t WHERE t.createdBy = :userName ") Page getBenDataByUser(@Param("userName") String userName, Pageable pageable); - @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benAddressId = :addressID") + // BenAddressId can have more than one mapping row (confirmed live: address-ID collisions + // between old/test registrations and real ones). Order by PK desc, same "latest wins" + // convention already used for findByBenRegIdFromMapping, so callers picking .get(0) get + // the newest mapping deterministically instead of whichever row the DB returns first. + @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benAddressId = :addressID ORDER BY t.benMapId DESC") List getByAddressID(@Param("addressID") BigInteger addressID); @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benRegId = :BenRegId") diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java index fa6acdf19..d3345f660 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java @@ -42,6 +42,9 @@ public interface FormSectionRepo extends JpaRepository { List findByFormVersion_VersionIdOrderByDisplayOrderAsc(Long versionId); + /** Read-path variant excluding removed (isActive=false) sections. */ + List findByFormVersion_VersionIdAndIsActiveTrueOrderByDisplayOrderAsc(Long versionId); + Optional findByFormVersion_VersionIdAndSectionUuid(Long versionId, String sectionUuid); Optional findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(Long versionId); diff --git a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java index c82040ea1..7ec5cad52 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java @@ -55,9 +55,11 @@ Optional findByQuestionOption_OptionIdAndActionTypeAndTargetSec * Loads all conditions for a set of options in one query. * JOIN FETCH ensures questionOption, targetQuestion, and targetSection are hydrated * so callers can group and resolve references without extra queries. + * Excludes removed (isActive=false) conditions — used only by read paths; reconciliation matching + * uses the natural-key finders above, which must see inactive rows too. */ @Query("SELECT c FROM OptionCondition c JOIN FETCH c.questionOption " + "LEFT JOIN FETCH c.targetQuestion LEFT JOIN FETCH c.targetSection " - + "WHERE c.questionOption.optionId IN :optionIds") + + "WHERE c.questionOption.optionId IN :optionIds AND c.isActive = true") List findByOptionIds(@Param("optionIds") Collection optionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java index ad8be0fa7..17e1ea616 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java @@ -47,8 +47,10 @@ Optional findBySectionQuestion_QuestionIdAndValidationTypeAn /** * Loads all validations for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. + * Excludes removed (isActive=false) validations — used only by read paths; reconciliation matching + * uses the natural-key finder above, which must see inactive rows too. */ @Query("SELECT v FROM QuestionValidation v JOIN FETCH v.sectionQuestion " - + "WHERE v.sectionQuestion.questionId IN :questionIds") + + "WHERE v.sectionQuestion.questionId IN :questionIds AND v.isActive = true") List findByQuestionIds(@Param("questionIds") Collection questionIds); } 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/impl/DynamicFormDefinitionServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java index c40b65d7b..638f44b00 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java @@ -280,7 +280,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/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index f6e508b90..56089f60c 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -134,8 +134,10 @@ public List> saveGeneralExamination(List exam.setCyanosis(getString(data, "cyanosis")); exam.setClubbingId(getInt(data, "clubbingId")); exam.setClubbing(getString(data, "clubbing")); - // hivStatus*/keyPopulationRiskFactor* are intentionally not set here — TB Screening - // is the single source of truth for these fields (see examToMap). + exam.setKeyPopulationRiskFactorIds(toJsonString(data.get("keyPopulationRiskFactorIds"))); + exam.setKeyPopulationRiskFactors(toJsonString(data.get("keyPopulationRiskFactors"))); + exam.setHivStatusId(getInt(data, "hivStatusId")); + exam.setHivStatus(getString(data, "hivStatus")); exam.setCreatedBy(getString(data, "createdBy")); exam.setModifiedBy(getString(data, "createdBy")); exam.setDeleted(false); From ab769f16a65db90396c681e412cf27ffddd1ad36 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 10:44:33 +0530 Subject: [PATCH 10/87] feat: change the flw-api version to 3.9.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1d3e88724..bfec2491f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.8.4 + 3.9.1 war FLW-API From 03a5cfbc132ba187a03403b2c492deb2843ecc8f Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 10:56:44 +0530 Subject: [PATCH 11/87] feat: update Stop TB service implementation --- .../com/iemr/flw/service/impl/StopTBServiceImpl.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 14dd9a654..c95364ee9 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -203,13 +203,10 @@ private Map examToMap(StopTBGeneralExamination e) { m.put("cyanosis", e.getCyanosis()); m.put("clubbingId", e.getClubbingId()); m.put("clubbing", e.getClubbing()); - // hivStatus*/keyPopulationRiskFactor* are sourced from TB Screening (the source of - // truth for these fields) rather than from this entity's own (deprecated) columns. - TBScreening screening = tbScreeningRepo.findByBenRegIDAndVisitCode(e.getBeneficiaryRegID(), e.getVisitCode()); - m.put("keyPopulationRiskFactorIds", screening != null ? screening.getKeyPopulationRiskFactorIds() : null); - m.put("keyPopulationRiskFactors", screening != null ? screening.getKeyPopulationRiskFactors() : null); - m.put("hivStatusId", screening != null ? screening.getHivStatusId() : null); - m.put("hivStatus", screening != null ? screening.getHivStatus() : null); + m.put("keyPopulationRiskFactorIds", e.getKeyPopulationRiskFactorIds()); + m.put("keyPopulationRiskFactors", e.getKeyPopulationRiskFactors()); + m.put("hivStatusId", e.getHivStatusId()); + m.put("hivStatus", e.getHivStatus()); m.put("referralToHWCNeeded", e.getReferralToHWCNeeded()); m.put("createdBy", e.getCreatedBy()); m.put("createdDate", e.getCreatedDate()); From 22dca925dad9d9841456417722b8f698564583f0 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 09:37:38 +0530 Subject: [PATCH 12/87] feat: add dynamic form migrator with new form version creation --- .../com/iemr/flw/dto/iemr/DynamicFormDTO.java | 6 + .../iemr/flw/mapper/DynamicFormMapper.java | 1 + .../V001_1_CreateContactTracingVersion2.java | 108 ++++++++++++++++++ .../service/DynamicFormDefinitionService.java | 2 +- .../DynamicFormDefinitionServiceImpl.java | 12 +- 5 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java diff --git a/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java b/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java index c9ecb63c4..9f401d390 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java @@ -21,6 +21,7 @@ */ package com.iemr.flw.dto.iemr; +import com.fasterxml.jackson.annotation.JsonInclude; import com.iemr.flw.masterEnum.FormType; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -62,4 +63,9 @@ public class DynamicFormDTO { @Valid private List sections = new ArrayList<>(); + + /** Every version's full definition, oldest first — populated only by getAllForms (the top-level + * fields above stay the latest version); omitted from the JSON elsewhere and ignored on write. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private List versions; } diff --git a/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java b/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java index 551e93a6d..9f7abac5b 100644 --- a/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java +++ b/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java @@ -48,6 +48,7 @@ public interface DynamicFormMapper { @Mapping(target = "sections", ignore = true) @Mapping(target = "versionNumber", ignore = true) + @Mapping(target = "versions", ignore = true) DynamicFormDTO toDto(DynamicForm entity); FormVersionDTO toDto(FormVersion entity); 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/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/impl/DynamicFormDefinitionServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java index 638f44b00..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)); From 42d3114836bb6aca38e53e661f2684702becc27b Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 13:00:59 +0530 Subject: [PATCH 13/87] feat: refactored code --- src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java | 2 +- src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java | 2 +- src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java index d3345f660..66192716c 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java @@ -50,7 +50,7 @@ public interface FormSectionRepo extends JpaRepository { Optional findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(Long versionId); /** Shifts displayOrder by delta for every section in [from, to] within a version — used to make room for/close a gap around an inserted or moved sibling. */ - @Modifying + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("UPDATE FormSection s SET s.displayOrder = s.displayOrder + :delta " + "WHERE s.formVersion.versionId = :versionId AND s.displayOrder BETWEEN :from AND :to") void shiftDisplayOrder(@Param("versionId") Long versionId, @Param("from") int from, diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java index 613abb266..0d8e87969 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java @@ -45,7 +45,7 @@ public interface QuestionOptionRepo extends JpaRepository Optional findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(Long questionId); /** Shifts displayOrder by delta for every option in [from, to] within a question — used to make room for/close a gap around an inserted or moved sibling. */ - @Modifying + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("UPDATE QuestionOption o SET o.displayOrder = o.displayOrder + :delta " + "WHERE o.sectionQuestion.questionId = :questionId AND o.displayOrder BETWEEN :from AND :to") void shiftDisplayOrder(@Param("questionId") Long questionId, @Param("from") int from, diff --git a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java index ae2a6744c..520993666 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java @@ -48,7 +48,7 @@ public interface SectionQuestionRepo extends JpaRepository findTopByFormSection_SectionIdOrderByDisplayOrderDesc(Long sectionId); /** Shifts displayOrder by delta for every question in [from, to] within a section — used to make room for/close a gap around an inserted or moved sibling. */ - @Modifying + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("UPDATE SectionQuestion q SET q.displayOrder = q.displayOrder + :delta " + "WHERE q.formSection.sectionId = :sectionId AND q.displayOrder BETWEEN :from AND :to") void shiftDisplayOrder(@Param("sectionId") Long sectionId, @Param("from") int from, From ca82fc8ba870312edfd5a09ff3f5c38cc6b813f0 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 13:17:09 +0530 Subject: [PATCH 14/87] feat: changed flw-api version to 3.12.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index bfec2491f..5fc0bc731 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.9.1 + 3.12.0 war FLW-API From 5b943613436833db0e07eb1aeb62fec2843d6083 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 25 Sep 2026 09:52:47 +0530 Subject: [PATCH 15/87] chore: bump pom version to 3.9.1 Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1d3e88724..bfec2491f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.8.4 + 3.9.1 war FLW-API From 599d092ac28f334dd0ef73110fdb46d7ca96b62d Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 28 Sep 2026 19:23:46 +0530 Subject: [PATCH 16/87] Updated the device integration according to updated workflow --- src/main/environment/common_ci.properties | 2 + src/main/environment/common_docker.properties | 2 + .../environment/common_example.properties | 42 +-- .../controller/DiagnosticOrderController.java | 20 +- .../iemr/flw/domain/iemr/DiagnosticOrder.java | 13 +- .../flw/dto/DiagnosticOrderRequestDto.java | 5 +- .../flw/dto/DiagnosticOrderResultDto.java | 2 +- .../dto/DiagnosticOrderStatusSummaryDto.java | 3 +- .../dto/ManualDiagnosticResultRequestDto.java | 4 +- .../provider/DiagnosticCancelResult.java | 14 + .../provider/DiagnosticProvider.java | 10 + .../provider/emrlite/EmrLiteProvider.java | 39 ++- .../emrlite/dto/EmrLiteCancelRequest.java | 13 + .../flw/masterEnum/DiagnosticOrderStatus.java | 7 +- .../flw/repo/iemr/DiagnosticOrderRepo.java | 23 +- .../flw/service/DiagnosticOrderService.java | 18 +- .../impl/DiagnosticDocumentServiceImpl.java | 2 +- .../impl/DiagnosticOrderServiceImpl.java | 239 ++++++++++++++---- .../impl/DiagnosticPollSchedulerService.java | 20 +- 19 files changed, 351 insertions(+), 127 deletions(-) create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 0bd9638b1..ad996282d 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -68,6 +68,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=@env.DIAGNOSTIC_EMRLITE_XRAY_USERNAME@ diagnostic.emrlite.xray.password=@env.DIAGNOSTIC_EMRLITE_XRAY_PASSWORD@ @@ -78,6 +79,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=@env.DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME@ diagnostic.emrlite.truenat.password=@env.DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 2b19223c7..b37a8e00e 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -67,6 +67,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} @@ -77,6 +78,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 9238d8be3..9583947c9 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -7,14 +7,14 @@ tm-url=http://localhost:8089/ spring.datasource.url=jdbc:mysql://localhost:3306/db_iemr spring.datasource.username=root -spring.datasource.password=1234 +spring.datasource.password=Hrhk@4321 spring.datasource.driver-class-name=com.mysql.jdbc.Driver ##--------------------------------------------## Secondary db------------------------------------------------------------------- secondary.datasource.url=jdbc:mysql://localhost:3306/db_identity secondary.datasource.username=root -secondary.datasource.password=1234 +secondary.datasource.password=Hrhk@4321 secondary.datasource.driver-class-name=com.mysql.jdbc.Driver springdoc.api-docs.enabled=true @@ -32,7 +32,7 @@ spring.redis.host=localhost # instead of silently storing vanID=NULL (which then never gets synced to central) stoptb.enforce.vanid=false # Stop TB: this deployment's van/camp ID, replacing the old Redis camp:vanID lookup -stoptb.van.id=0 +stoptb.van.id=1 cors.allowed-origins=http://localhost:* @@ -49,40 +49,46 @@ abha.client.secret= abha.token.url= abha.xcmid= +# GovThealth API Config +govthealth.user.details.url=$GOVTHEALTH_USER_DETAILS_URL} +govthealth.user.id=$GOVTHEALTH_USER_ID} +govthealth.password=$GOVTHEALTH_PASSWORD} # ── Diagnostic Device Integration ──────────────────────────────────────────── # diagnostic.provider.xray / diagnostic.provider.truenat select the vendor per orderType family # (XRAY_CHEST vs MTB/MTB_PLUS/MDR_RIF), each must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode -diagnostic.provider.xray=${DIAGNOSTIC_PROVIDER_XRAY} -diagnostic.provider.truenat=${DIAGNOSTIC_PROVIDER_TRUENAT} +diagnostic.provider.xray=EMRLITE +diagnostic.provider.truenat=EMRLITE # EMR Lite - xray group (same base-url as truenat for now; split so a distinct vendor/tenant can be # swapped in for either group later without a code change) -diagnostic.emrlite.xray.base-url=${DIAGNOSTIC_EMRLITE_XRAY_BASE_URL} +diagnostic.emrlite.xray.base-url=http://152.67.167.101:8000 diagnostic.emrlite.xray.login-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/login/ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ -diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} -diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ +diagnostic.emrlite.xray.username=piramal +diagnostic.emrlite.xray.password=piramal@123 # EMR Lite - truenat group (MTB/MTB_PLUS/MDR_RIF); same value as xray for now -diagnostic.emrlite.truenat.base-url=${DIAGNOSTIC_EMRLITE_TRUENAT_BASE_URL} +diagnostic.emrlite.truenat.base-url=http://152.67.167.101:8000 diagnostic.emrlite.truenat.login-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/login/ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ -diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} -diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ +diagnostic.emrlite.truenat.username=piramal +diagnostic.emrlite.truenat.password=piramal@123 -diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} -diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} +diagnostic.emrlite.token-ttl-seconds=3600 +diagnostic.emrlite.refresh-ttl-seconds=36000 -diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} -diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} -# Daily cutoff (cron expression) after which all outstanding PENDING/IN_PROGRESS orders are marked EXPIRED -diagnostic.poll.expiry-cron=${DIAGNOSTIC_POLL_EXPIRY_CRON} +diagnostic.poll.xray.tick-ms=30000 +diagnostic.poll.truenat.tick-ms=60000 +# Daily cutoff (cron expression) after which all outstanding PENDING orders are marked CLOSED +diagnostic.poll.expiry-cron=0 50 23 * * * -diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} +diagnostic.documents.storage-root=/home/unthinkable-lap/Desktop/diagnostic-documt diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java index 8eb9476b7..1725b5afc 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -37,10 +37,11 @@ public DiagnosticOrderController(DiagnosticOrderService diagnosticOrderService) @PostMapping("/order/push") @Operation(summary = "Push a diagnostic order to the configured provider") - public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { + public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrder order = diagnosticOrderService.createAndPushOrder(request); + DiagnosticOrder order = diagnosticOrderService.createAndPushOrderByUser(request, jwtToken); response.setResponse(new Gson().toJson(order)); } catch (Exception e) { logger.error("Error in pushOrder: {}", e.getMessage()); @@ -128,14 +129,17 @@ public String pollOrder(@RequestParam Long beneficiaryId, @RequestParam String o } @PostMapping("/order/manualResult") - @Operation(summary = "Manually submit a diagnostic result for a beneficiary's latest order of the given " - + "type, for use when no vendor device is integrated. Stored as-is; tbPresence, tbConfidence, and " - + "drugResistancePresence are left null since nothing derives them. Rejected if that order is " - + "already COMPLETED.") - public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request) { + @Operation(summary = "Exactly one of resultSummary/reasonToClose is required. resultSummary: manually submit " + + "a diagnostic result for a beneficiary's latest order of the given type, for use when no vendor " + + "device is integrated. Stored as-is; tbPresence, tbConfidence, and drugResistancePresence are left " + + "null since nothing derives them. Rejected if that order is already COMPLETED. reasonToClose: close " + + "the order instead (same behavior as reasonToClose on /order/push), sourcing patient/orderEvent " + + "from the beneficiary's existing order for this type.") + public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request); + DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request, jwtToken); response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in submitManualResult: {}", e.getMessage()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java index 197999fe2..f8f69e037 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -8,8 +8,7 @@ import java.sql.Timestamp; @Entity -@Table(name = "tb_diagnostic_order", schema = "db_iemr", - uniqueConstraints = @UniqueConstraint(columnNames = {"beneficiary_id", "visitCode", "order_type"})) +@Table(name = "tb_diagnostic_order", schema = "db_iemr") @Data public class DiagnosticOrder { @@ -53,12 +52,15 @@ public class DiagnosticOrder { @Column(name = "error_message", columnDefinition = "TEXT") private String errorMessage; - @Column(name = "reason_for_refusal", columnDefinition = "TEXT") - private String reasonForRefusal; + @Column(name = "reason_to_close", columnDefinition = "TEXT") + private String reasonToClose; @Column(name = "push_response_json", columnDefinition = "LONGTEXT") private String pushResponseJson; + @Column(name = "cancel_response_json", columnDefinition = "LONGTEXT") + private String cancelResponseJson; + @Column(name = "patient_first_name", length = 100) private String patientFirstName; @@ -82,6 +84,9 @@ public class DiagnosticOrder { @Column(name = "modified_by") private String modifiedBy; + @Column(name = "manually_entered_by") + private String manuallyEnteredBy; + @UpdateTimestamp @Column(name = "last_mod_date") private Timestamp lastModDate; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java index 6ed0e03fe..abf8475ea 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -14,15 +14,12 @@ public class DiagnosticOrderRequestDto { @NotNull private Long beneficiaryId; - @NotNull - private Long visitCode; - @NotNull private String orderType; private String orderEvent; - private String reasonForRefusal; + private String reasonToClose; @NotNull @Valid diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java index 92eaa03d5..531800f7d 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -13,7 +13,7 @@ public class DiagnosticOrderResultDto { private String providerStatus; private String resultSummary; private String errorMessage; - private String reasonForRefusal; + private String reasonToClose; private Boolean tbPresence; private Double tbConfidence; private Boolean drugResistancePresence; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java index 4e18e0bb1..88eafcab7 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -12,8 +12,7 @@ public class DiagnosticOrderStatusSummaryDto { private List awaitingProviderResult; private List completed; - private List pollingTimedOut; private List failed; - private List refused; + private List closed; private List awaitingManualEntry; } diff --git a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java index 12bd70cdb..cd6a10a83 100644 --- a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java @@ -1,6 +1,5 @@ package com.iemr.flw.dto; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.NoArgsConstructor; @@ -15,6 +14,7 @@ public class ManualDiagnosticResultRequestDto { @NotNull private String orderType; - @NotBlank private String resultSummary; + + private String reasonToClose; } diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java new file mode 100644 index 000000000..f025bacba --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java @@ -0,0 +1,14 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticCancelResult { + private boolean success; + private String rawResponseJson; + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java index 9737cba02..47cd3ee30 100644 --- a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java @@ -11,6 +11,16 @@ public interface DiagnosticProvider { DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception; + /** + * Notifies the vendor that this order is being closed out without (or regardless of) a result, + * so it stops tracking/processing it on their end. Only call for an order actually known to the + * vendor (i.e. previously pushed) — calling this for an order that was never pushed is meaningless. + * The raw response is returned (on both success and a provider-level rejection) so the caller can + * persist it; only a transport-level failure (unreachable, timeout) throws instead, since there's + * no response body to return in that case. + */ + DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception; + /** * Lightweight liveness check against the vendor group serving this orderType. Never throws — * returns false on any failure (unreachable, non-2xx, unparsable response, unconfigured URL). diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java index 0aa20d631..754711ce1 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -4,6 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -60,6 +61,12 @@ public class EmrLiteProvider implements DiagnosticProvider { @Value("${diagnostic.emrlite.truenat.ping-url}") private String truenatPingUrl; + @Value("${diagnostic.emrlite.xray.cancel-url}") + private String xrayCancelUrl; + + @Value("${diagnostic.emrlite.truenat.cancel-url}") + private String truenatCancelUrl; + @Autowired @Qualifier("emrLiteRestTemplate") private RestTemplate restTemplate; @@ -158,6 +165,34 @@ public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAss tbPresence, tbConfidence, drugResistancePresence); } + @Override + public DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception { + EmrLiteCancelRequest request = new EmrLiteCancelRequest(order.getExternalOrderId(), reason); + String cancelUrl = DiagnosticOrderType.XRAY_CHEST.name().equals(order.getOrderType()) + ? xrayCancelUrl : truenatCancelUrl; + String responseBody; + try { + responseBody = doPost(cancelUrl, gson.toJson(request)); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.warn("Provider HTTP error on order cancel: externalOrderId={}, status={}", + order.getExternalOrderId(), e.getStatusCode()); + return new DiagnosticCancelResult(false, body, "HTTP " + e.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (envelope == null || !envelope.isSuccess()) { + logger.warn("Provider rejected order cancel: externalOrderId={}, message={}", + order.getExternalOrderId(), envelope != null ? envelope.getMessage() : null); + return new DiagnosticCancelResult(false, responseBody, + envelope != null ? envelope.getMessage() : "empty response"); + } + + logger.info("Order cancelled on provider: externalOrderId={}, reason={}", + order.getExternalOrderId(), reason); + return new DiagnosticCancelResult(true, responseBody, null); + } + @Override public boolean checkHealth(DiagnosticOrderType orderType) { String pingUrl = DiagnosticOrderType.XRAY_CHEST.equals(orderType) ? xrayPingUrl : truenatPingUrl; @@ -236,8 +271,8 @@ private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { DiagnosticOrderStatus topLevelStatus = DiagnosticOrderStatus.fromString(result.getStatus()); if (topLevelStatus == DiagnosticOrderStatus.COMPLETED || topLevelStatus == DiagnosticOrderStatus.FAILED) { // Components contradict a terminal top-level status — only the components map may - // resolve to a terminal state; treat this as still in progress. - return DiagnosticOrderStatus.IN_PROGRESS; + // resolve to a terminal state; treat this as still pending. + return DiagnosticOrderStatus.PENDING; } return topLevelStatus; } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java new file mode 100644 index 000000000..51f8320da --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteCancelRequest { + private String externalOrderId; + private String reason; +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java index 9a0a8ec23..ce444fcb0 100644 --- a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -5,13 +5,14 @@ public enum DiagnosticOrderStatus { IN_PROGRESS, COMPLETED, FAILED, - CANCELLED, - EXPIRED, - REFUSED, + CLOSED, MANUAL_ENTRY; public static DiagnosticOrderStatus fromString(String value) { if (value == null) return PENDING; + // IN_PROGRESS is kept only as a legacy/historical value (old rows, backward-compat reporting + // queries) — it is never freshly assigned. A vendor reporting "IN_PROGRESS" is just PENDING. + if (IN_PROGRESS.name().equalsIgnoreCase(value)) return PENDING; for (DiagnosticOrderStatus status : values()) { if (status.name().equalsIgnoreCase(value)) { return status; diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index fd7c72892..fa3e79b48 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -16,11 +16,8 @@ public interface DiagnosticOrderRepo extends JpaRepository findByExternalOrderId(String externalOrderId); - @Query("SELECT o FROM DiagnosticOrder o WHERE o.beneficiaryId = :beneficiaryId AND o.visitCode = :visitCode " + - "AND o.orderType = :orderType AND o.deleted = false") - Optional findByBeneficiaryIdAndVisitCodeAndOrderType(@Param("beneficiaryId") Long beneficiaryId, - @Param("visitCode") Long visitCode, - @Param("orderType") String orderType); + Optional findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + Long beneficiaryId, Long visitCode, String orderType); Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( Long beneficiaryId, String orderType); @@ -55,7 +52,7 @@ Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrd // beneficiary with an old terminal order and a new in-flight retest of the same orderType // is bucketed only by the retest, not both. @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status NOT IN ('COMPLETED', 'EXPIRED', 'FAILED', 'REFUSED', 'MANUAL_ENTRY') " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + @@ -74,16 +71,6 @@ List findBeneficiaryIdsAwaitingProviderResult(@Param("orderType") String o List findBeneficiaryIdsCompleted(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'EXPIRED' " + - "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + - "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + - "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + - "AND (:villageId IS NULL OR b.villageID = :villageId) " + - "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsPollingTimedOut(@Param("orderType") String orderType, - @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + "AND o.status = 'FAILED' " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + @@ -95,13 +82,13 @@ List findBeneficiaryIdsFailed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'REFUSED' " + + "AND o.status IN ('CLOSED', 'CANCELLED', 'EXPIRED', 'REFUSED') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsRefused(@Param("orderType") String orderType, + List findBeneficiaryIdsClosed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java index 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/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); } } From e38f64dc3aca01ec95f4e853e9b70bbd8852897c Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 28 Sep 2026 19:25:28 +0530 Subject: [PATCH 17/87] Updated the device integration according to updated workflow --- .../environment/common_example.properties | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 9583947c9..b0238b0c6 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -7,14 +7,14 @@ tm-url=http://localhost:8089/ spring.datasource.url=jdbc:mysql://localhost:3306/db_iemr spring.datasource.username=root -spring.datasource.password=Hrhk@4321 +spring.datasource.password=1234 spring.datasource.driver-class-name=com.mysql.jdbc.Driver ##--------------------------------------------## Secondary db------------------------------------------------------------------- secondary.datasource.url=jdbc:mysql://localhost:3306/db_identity secondary.datasource.username=root -secondary.datasource.password=Hrhk@4321 +secondary.datasource.password=1234 secondary.datasource.driver-class-name=com.mysql.jdbc.Driver springdoc.api-docs.enabled=true @@ -32,7 +32,7 @@ spring.redis.host=localhost # instead of silently storing vanID=NULL (which then never gets synced to central) stoptb.enforce.vanid=false # Stop TB: this deployment's van/camp ID, replacing the old Redis camp:vanID lookup -stoptb.van.id=1 +stoptb.van.id=0 cors.allowed-origins=http://localhost:* @@ -50,45 +50,45 @@ abha.token.url= abha.xcmid= # GovThealth API Config -govthealth.user.details.url=$GOVTHEALTH_USER_DETAILS_URL} -govthealth.user.id=$GOVTHEALTH_USER_ID} -govthealth.password=$GOVTHEALTH_PASSWORD} +govthealth.user.details.url={$GOVTHEALTH_USER_DETAILS_URL} +govthealth.user.id={$GOVTHEALTH_USER_ID} +govthealth.password={$GOVTHEALTH_PASSWORD} # ── Diagnostic Device Integration ──────────────────────────────────────────── # diagnostic.provider.xray / diagnostic.provider.truenat select the vendor per orderType family # (XRAY_CHEST vs MTB/MTB_PLUS/MDR_RIF), each must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode -diagnostic.provider.xray=EMRLITE -diagnostic.provider.truenat=EMRLITE +diagnostic.provider.xray=${DIAGNOSTIC_PROVIDER_XRAY} +diagnostic.provider.truenat=${DIAGNOSTIC_PROVIDER_TRUENAT} # EMR Lite - xray group (same base-url as truenat for now; split so a distinct vendor/tenant can be # swapped in for either group later without a code change) -diagnostic.emrlite.xray.base-url=http://152.67.167.101:8000 +diagnostic.emrlite.xray.base-url=${DIAGNOSTIC_EMRLITE_XRAY_BASE_URL} diagnostic.emrlite.xray.login-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/login/ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ -diagnostic.emrlite.xray.username=piramal -diagnostic.emrlite.xray.password=piramal@123 +diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} +diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} # EMR Lite - truenat group (MTB/MTB_PLUS/MDR_RIF); same value as xray for now -diagnostic.emrlite.truenat.base-url=http://152.67.167.101:8000 +diagnostic.emrlite.truenat.base-url=${DIAGNOSTIC_EMRLITE_TRUENAT_BASE_URL} diagnostic.emrlite.truenat.login-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/login/ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ -diagnostic.emrlite.truenat.username=piramal -diagnostic.emrlite.truenat.password=piramal@123 +diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} +diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} -diagnostic.emrlite.token-ttl-seconds=3600 -diagnostic.emrlite.refresh-ttl-seconds=36000 +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} -diagnostic.poll.xray.tick-ms=30000 -diagnostic.poll.truenat.tick-ms=60000 -# Daily cutoff (cron expression) after which all outstanding PENDING orders are marked CLOSED -diagnostic.poll.expiry-cron=0 50 23 * * * +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} +# Daily cutoff (cron expression) after which all outstanding PENDING/IN_PROGRESS orders are marked EXPIRED +diagnostic.poll.expiry-cron=${DIAGNOSTIC_POLL_EXPIRY_CRON} -diagnostic.documents.storage-root=/home/unthinkable-lap/Desktop/diagnostic-documt +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} From 1e4a9f7236a2b6c15232e9967a4dd384297ab366 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 29 Sep 2026 10:10:24 +0530 Subject: [PATCH 18/87] Updated the device integration according to updated workflow --- src/main/environment/common_example.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index b0238b0c6..a1d4ebe55 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -50,9 +50,9 @@ abha.token.url= abha.xcmid= # GovThealth API Config -govthealth.user.details.url={$GOVTHEALTH_USER_DETAILS_URL} -govthealth.user.id={$GOVTHEALTH_USER_ID} -govthealth.password={$GOVTHEALTH_PASSWORD} +govthealth.user.details.url=${GOVTHEALTH_USER_DETAILS_URL} +govthealth.user.id=${GOVTHEALTH_USER_ID} +govthealth.password=${GOVTHEALTH_PASSWORD} # ── Diagnostic Device Integration ──────────────────────────────────────────── # diagnostic.provider.xray / diagnostic.provider.truenat select the vendor per orderType family From b2923cd8927a0e0add10369a22a90901e85e119f Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 29 Sep 2026 11:15:10 +0530 Subject: [PATCH 19/87] Updated the device integration according to updated workflow --- .../impl/DiagnosticOrderServiceImpl.java | 98 +++++++++++++++---- .../impl/DiagnosticPollSchedulerService.java | 2 + 2 files changed, 82 insertions(+), 18 deletions(-) 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 8aeb045b7..12ef9afc6 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -4,6 +4,7 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.domain.iemr.DiagnosticResult; import com.iemr.flw.domain.iemr.TBSuspected; +import com.iemr.flw.domain.iemr.User; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; @@ -21,6 +22,7 @@ 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.EmployeeMasterRepo; import com.iemr.flw.repo.iemr.TBSuspectedRepo; import com.iemr.flw.repo.iemr.UserServiceRoleRepo; import com.iemr.flw.service.CampConfigService; @@ -47,7 +49,16 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { private static final Set BLOCKING_STATUSES = Set.of( DiagnosticOrderStatus.PENDING.name(), - DiagnosticOrderStatus.COMPLETED.name()); + DiagnosticOrderStatus.COMPLETED.name(), + DiagnosticOrderStatus.MANUAL_ENTRY.name()); + + private static final Set NON_REUSABLE_ON_CLOSE_STATUSES = Set.of( + DiagnosticOrderStatus.COMPLETED.name(), + DiagnosticOrderStatus.FAILED.name(), + DiagnosticOrderStatus.CLOSED.name()); + + private static final String XRAY_INVALID_RESULT = "AI Invalid Result"; + private static final String SPUTUM_INVALID_RESULT = "Invalid/Error"; @Autowired private DiagnosticOrderRepo diagnosticOrderRepo; @@ -76,11 +87,26 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @Autowired private JwtUtil jwtUtil; + @Autowired + private EmployeeMasterRepo employeeMasterRepo; + @Override 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)); + return createAndPushOrder(request, resolveActingUserFirstName(jwtToken)); + } + + // The acting user's m_user.FirstName is what gets stored in createdBy/modifiedBy/manuallyEnteredBy. + // Falls back to the JWT username if the user can't be resolved, so a lookup miss never blocks the flow. + private String resolveActingUserFirstName(String jwtToken) { + try { + User user = employeeMasterRepo.findUserByUserID(jwtUtil.extractUserId(jwtToken)); + if (user != null && user.getFirstName() != null && !user.getFirstName().isBlank()) { + return user.getFirstName(); + } + } catch (Exception e) { + logger.warn("Could not resolve acting user's first name from m_user: {}", e.getMessage()); + } + return jwtUtil.extractUsername(jwtToken); } @Override @@ -158,11 +184,14 @@ private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, St order.setProviderCode(providerCode); order.setOrderType(orderType.name()); order.setExternalOrderId(externalOrderId); - order.setStatus(DiagnosticOrderStatus.PENDING.name()); + boolean noVendor = providerCode == null || providerCode.isBlank(); + order.setStatus(noVendor ? DiagnosticOrderStatus.MANUAL_ENTRY.name() : DiagnosticOrderStatus.PENDING.name()); order.setPatientFirstName(patientFirstName); order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); + order.setCreatedBy(createdBy); + order.setModifiedBy(createdBy); try { order = diagnosticOrderRepo.save(order); @@ -177,10 +206,10 @@ private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, St throw dive; } - if (providerCode == null || providerCode.isBlank()) { + if (noVendor) { logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry", orderType, beneficiaryId); - // Status stays PENDING (set above) — awaiting manual entry via submitManualResult. + // Status is MANUAL_ENTRY (set above) — awaiting manual entry via submitManualResult. order = diagnosticOrderRepo.save(order); if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); return order; @@ -250,16 +279,16 @@ private DiagnosticOrder closeOrderManually(Long beneficiaryId, DiagnosticOrderTy // 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 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. + // order. A COMPLETED, FAILED or already-CLOSED latest order is left untouched (treated as "not found") + // and a new CLOSED row is created instead, so its history (e.g. a FAILED row's errorMessage) survives. + // 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 reasonToClose, String actingUserId) { Optional latest = diagnosticOrderRepo .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()); - if (latest.isPresent() && (DiagnosticOrderStatus.COMPLETED.name().equals(latest.get().getStatus()) - || DiagnosticOrderStatus.CLOSED.name().equals(latest.get().getStatus()))) { + if (latest.isPresent() && NON_REUSABLE_ON_CLOSE_STATUSES.contains(latest.get().getStatus())) { latest = Optional.empty(); } @@ -280,6 +309,7 @@ private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, Dia order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); + order.setCreatedBy(actingUserId); } order.setStatus(DiagnosticOrderStatus.CLOSED.name()); order.setReasonToClose(reasonToClose); @@ -329,6 +359,15 @@ public void notifyProviderOrderClosed(DiagnosticOrder order, String reason) { @Override public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { + return processResult(order, pollResult, false, "SYSTEM"); + } + + // writeBackWhenClosed: a manually submitted invalid result closes the order but its result is + // still written back to tb_suspected, same as a COMPLETED one. + // actingUser: stored as createdBy/modifiedBy — "SYSTEM" for vendor polls, the user's first + // name for a manually submitted result. + private DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult, + boolean writeBackWhenClosed, String actingUser) throws Exception { Optional existingResult = diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()); DiagnosticResult result = existingResult.orElseGet(DiagnosticResult::new); result.setExternalOrderId(order.getExternalOrderId()); @@ -344,7 +383,8 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP result.setTbPresence(pollResult.getTbPresence()); result.setTbConfidence(pollResult.getTbConfidence()); result.setDrugResistancePresence(pollResult.getDrugResistancePresence()); - result.setCreatedBy("SYSTEM"); + result.setCreatedBy(actingUser); + result.setModifiedBy(actingUser); if (result.getVanID() == null) { // Inherit from the parent order rather than re-reading Redis — the result belongs // to whichever van originated the order, not whichever van happens to be polling now. @@ -367,10 +407,12 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP order.setProviderOrderId(pollResult.getProviderOrderId()); } order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setModifiedBy(actingUser); order.setProcessed("N"); diagnosticOrderRepo.save(order); - if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { + if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus()) + || (writeBackWhenClosed && DiagnosticOrderStatus.CLOSED.name().equals(order.getStatus()))) { recordTbSuspectedResult(order, result); } @@ -444,6 +486,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.setModifiedBy("SYSTEM"); order.setProcessed("N"); diagnosticOrderRepo.save(order); return null; @@ -503,6 +546,10 @@ public DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visi throw new IllegalStateException("Cannot retry polling for order in terminal status " + status + " — create a new order instead"); } + if (DiagnosticOrderStatus.MANUAL_ENTRY.name().equals(status)) { + throw new IllegalStateException("Cannot retry polling for a MANUAL_ENTRY order — no vendor is involved, " + + "submit the result via manualResult instead"); + } // retriedAt is kept as an audit timestamp only — the scheduler no longer uses it to anchor a // poll window; a retried order is simply picked up on the next regular tick like any other @@ -602,9 +649,7 @@ public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequest 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); + String actingUserName = resolveActingUserFirstName(jwtToken); if (hasReasonToClose) { DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); @@ -619,8 +664,25 @@ public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequest + request.getBeneficiaryId() + ", orderType=" + request.getOrderType() + ")"); } order.setManuallyEnteredBy(actingUserName); + // An invalid test outcome is still recorded exactly like a normal result (result row + + // tb_suspected write-back), but the order is CLOSED rather than COMPLETED. + DiagnosticOrderStatus status = isInvalidResult(order.getOrderType(), request.getResultSummary()) + ? DiagnosticOrderStatus.CLOSED + : DiagnosticOrderStatus.COMPLETED; DiagnosticPollResult pollResult = new DiagnosticPollResult( - DiagnosticOrderStatus.COMPLETED, null, request.getResultSummary(), null, null, null, null, null, null); - return processResult(order, pollResult); + status, null, request.getResultSummary(), null, null, null, null, null, null); + return processResult(order, pollResult, true, actingUserName); + } + + private static boolean isInvalidResult(String orderTypeCode, String resultSummary) { + String summary = resultSummary.trim(); + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderTypeCode); + if (type == DiagnosticOrderType.XRAY_CHEST) { + return XRAY_INVALID_RESULT.equalsIgnoreCase(summary); + } + if (type == DiagnosticOrderType.MTB || type == DiagnosticOrderType.MDR_RIF) { + return SPUTUM_INVALID_RESULT.equalsIgnoreCase(summary); + } + return false; } } 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 2dbb183f9..2259d934c 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -95,6 +95,7 @@ private void expire(DiagnosticOrder order) { order.setStatus(DiagnosticOrderStatus.CLOSED.name()); order.setReasonToClose(reason); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setModifiedBy("SYSTEM"); order.setProcessed("N"); diagnosticOrderRepo.save(order); diagnosticOrderService.notifyProviderOrderClosed(order, reason); @@ -110,6 +111,7 @@ private void pollSingle(DiagnosticOrder order) { order.setRetryCount(order.getRetryCount() + 1); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); order.setErrorMessage(e.getMessage()); + order.setModifiedBy("SYSTEM"); order.setProcessed("N"); diagnosticOrderRepo.save(order); } From 127f8421710c4c8ce3b0f9771c6e58b59d55fa23 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 29 Sep 2026 15:50:41 +0530 Subject: [PATCH 20/87] Updated the device integration according to updated workflow --- .../impl/DiagnosticOrderServiceImpl.java | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) 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 12ef9afc6..19045367e 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -92,19 +92,17 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @Override public DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception { - return createAndPushOrder(request, resolveActingUserFirstName(jwtToken)); + return createAndPushOrder(request, resolveActingUserName(jwtToken)); } - // The acting user's m_user.FirstName is what gets stored in createdBy/modifiedBy/manuallyEnteredBy. - // Falls back to the JWT username if the user can't be resolved, so a lookup miss never blocks the flow. - private String resolveActingUserFirstName(String jwtToken) { + private String resolveActingUserName(String jwtToken) { try { User user = employeeMasterRepo.findUserByUserID(jwtUtil.extractUserId(jwtToken)); - if (user != null && user.getFirstName() != null && !user.getFirstName().isBlank()) { - return user.getFirstName(); + if (user != null && user.getUserName() != null && !user.getUserName().isBlank()) { + return user.getUserName(); } } catch (Exception e) { - logger.warn("Could not resolve acting user's first name from m_user: {}", e.getMessage()); + logger.warn("Could not resolve acting user's username from m_user: {}", e.getMessage()); } return jwtUtil.extractUsername(jwtToken); } @@ -362,10 +360,6 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP return processResult(order, pollResult, false, "SYSTEM"); } - // writeBackWhenClosed: a manually submitted invalid result closes the order but its result is - // still written back to tb_suspected, same as a COMPLETED one. - // actingUser: stored as createdBy/modifiedBy — "SYSTEM" for vendor polls, the user's first - // name for a manually submitted result. private DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult, boolean writeBackWhenClosed, String actingUser) throws Exception { Optional existingResult = diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()); @@ -649,7 +643,7 @@ public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequest throw new IllegalArgumentException("Exactly one of resultSummary or reasonToClose must be provided"); } - String actingUserName = resolveActingUserFirstName(jwtToken); + String actingUserName = resolveActingUserName(jwtToken); if (hasReasonToClose) { DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); From 85e6ab51dd9ef00e5d26ff351efb7a3121e0ecfb Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Wed, 30 Sep 2026 17:44:50 +0530 Subject: [PATCH 21/87] Updated the device integration according to updated workflow --- .../impl/DiagnosticOrderServiceImpl.java | 85 +++++++++++++++++-- 1 file changed, 77 insertions(+), 8 deletions(-) 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 19045367e..4d62aac50 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -57,8 +57,12 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { DiagnosticOrderStatus.FAILED.name(), DiagnosticOrderStatus.CLOSED.name()); + // Invalid test outcomes: the result is still recorded (result row, tb_suspected write-back, documents) + // but the order is CLOSED instead of COMPLETED, so a retest can be pushed. The vendor reports an + // invalid MTB/MDR_RIF run as "Error-2"; a manual entry uses "Invalid/Error". private static final String XRAY_INVALID_RESULT = "AI Invalid Result"; - private static final String SPUTUM_INVALID_RESULT = "Invalid/Error"; + private static final String SPUTUM_INVALID_POLLED_RESULT = "Error-2"; + private static final String SPUTUM_INVALID_MANUAL_RESULT = "Invalid/Error"; @Autowired private DiagnosticOrderRepo diagnosticOrderRepo; @@ -213,6 +217,12 @@ private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, St return order; } + return pushToProvider(order, providerCode); + } + + // Pushes an already-saved PENDING order to the vendor and saves the outcome — FAILED (with the + // error) if the push is rejected or throws. Shared by createAndPushOrder and pushRetestOrder. + private DiagnosticOrder pushToProvider(DiagnosticOrder order, String providerCode) { try { DiagnosticProvider provider = providerFactory.getProvider(providerCode); DiagnosticPushResult pushResult = provider.pushOrder(order); @@ -488,9 +498,16 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); DiagnosticPollResult result = provider.pollResult(order, false); if (DiagnosticOrderStatus.COMPLETED.equals(result.getStatus())) { - // Save as COMPLETED now, without assets, so a failure fetching/ingesting assets below - // doesn't also lose the already-confirmed completed status (see pollSingle's catch). - processResult(order, result); + // Save as COMPLETED (or CLOSED for an invalid outcome) now, without assets, so a failure + // fetching/ingesting assets below doesn't also lose the already-confirmed status (see + // pollSingle's catch). + boolean invalid = closeIfInvalidPolledResult(order, result); + processResult(order, result, invalid, "SYSTEM"); + if (invalid) { + // Before the asset fetch below: if that throws, this CLOSED order is never polled + // again, so the retest must already exist by then. + pushRetestOrder(order); + } result = provider.pollResult(order, true); // Only ingest the documents this time — the result/order fields were already saved above // and this second, asset-bearing response carries the same status/summary, so re-saving @@ -527,7 +544,8 @@ public DiagnosticOrderResultDto triggerManualPoll(Long beneficiaryId, String ord DiagnosticOrder order = resolveOrder(beneficiaryId, orderType, visitCode); DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); DiagnosticPollResult pollResult = provider.pollResult(order, true); - return processResult(order, pollResult); + boolean invalid = closeIfInvalidPolledResult(order, pollResult); + return processResult(order, pollResult, invalid, "SYSTEM"); } @Override @@ -660,7 +678,8 @@ public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequest order.setManuallyEnteredBy(actingUserName); // An invalid test outcome is still recorded exactly like a normal result (result row + // tb_suspected write-back), but the order is CLOSED rather than COMPLETED. - DiagnosticOrderStatus status = isInvalidResult(order.getOrderType(), request.getResultSummary()) + DiagnosticOrderStatus status = isInvalidResult(order.getOrderType(), request.getResultSummary(), + SPUTUM_INVALID_MANUAL_RESULT) ? DiagnosticOrderStatus.CLOSED : DiagnosticOrderStatus.COMPLETED; DiagnosticPollResult pollResult = new DiagnosticPollResult( @@ -668,14 +687,64 @@ public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequest return processResult(order, pollResult, true, actingUserName); } - private static boolean isInvalidResult(String orderTypeCode, String resultSummary) { + // sputumInvalidResult differs by source: SPUTUM_INVALID_POLLED_RESULT for vendor polls, + // SPUTUM_INVALID_MANUAL_RESULT for manual entry. The X-ray value is the same for both. + private static boolean isInvalidResult(String orderTypeCode, String resultSummary, String sputumInvalidResult) { + if (resultSummary == null) { + return false; + } String summary = resultSummary.trim(); DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderTypeCode); if (type == DiagnosticOrderType.XRAY_CHEST) { return XRAY_INVALID_RESULT.equalsIgnoreCase(summary); } if (type == DiagnosticOrderType.MTB || type == DiagnosticOrderType.MDR_RIF) { - return SPUTUM_INVALID_RESULT.equalsIgnoreCase(summary); + return sputumInvalidResult.equalsIgnoreCase(summary); + } + return false; + } + + // Automatic retest after a polled invalid outcome: a brand new PENDING row copying the closed + // order's beneficiary, visit, vendor and patient details, with only a fresh externalOrderId, + // pushed to the vendor straight away. Never throws — a failure here must not undo the close. + private void pushRetestOrder(DiagnosticOrder closed) { + try { + DiagnosticOrder retest = new DiagnosticOrder(); + retest.setVanID(closed.getVanID()); + retest.setParkingPlaceID(closed.getParkingPlaceID()); + retest.setOrderEvent(closed.getOrderEvent()); + retest.setBeneficiaryId(closed.getBeneficiaryId()); + retest.setVisitCode(closed.getVisitCode()); + retest.setProviderServiceName(closed.getProviderServiceName()); + retest.setProviderCode(closed.getProviderCode()); + retest.setOrderType(closed.getOrderType()); + retest.setExternalOrderId(String.format("%s-%d-%s", UUID.randomUUID(), closed.getVisitCode(), + closed.getOrderType())); + retest.setStatus(DiagnosticOrderStatus.PENDING.name()); + retest.setPatientFirstName(closed.getPatientFirstName()); + retest.setPatientLastName(closed.getPatientLastName()); + retest.setPatientDateOfBirth(closed.getPatientDateOfBirth()); + retest.setPatientSex(closed.getPatientSex()); + retest.setCreatedBy("SYSTEM"); + retest.setModifiedBy("SYSTEM"); + retest = diagnosticOrderRepo.save(retest); + retest = pushToProvider(retest, closed.getProviderCode()); + logger.info("Retest order created after invalid result: closedOrderId={}, retestOrderId={}, " + + "externalOrderId={}, status={}", closed.getId(), retest.getId(), retest.getExternalOrderId(), + retest.getStatus()); + } catch (Exception e) { + logger.error("Failed to create retest order after invalid result, closedOrderId={}: {}", + closed.getId(), e.getMessage()); + } + } + + // A vendor poll that COMPLETED with an invalid outcome is downgraded to CLOSED in place. + // Returns true when that happened, so the caller still writes the result back to tb_suspected. + private static boolean closeIfInvalidPolledResult(DiagnosticOrder order, DiagnosticPollResult result) { + if (DiagnosticOrderStatus.COMPLETED.equals(result.getStatus()) + && isInvalidResult(order.getOrderType(), result.getResultSummary(), SPUTUM_INVALID_POLLED_RESULT)) { + result.setStatus(DiagnosticOrderStatus.CLOSED); + return true; } return false; } From 3e60eabea0caf6c48784ea57156fbd33f081b2fa Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Mon, 5 Oct 2026 13:59:18 +0530 Subject: [PATCH 22/87] feat: stop-584 dynamic form backward compatibility changes --- .../flw/controller/DynamicFormController.java | 9 +++ .../iemr/flw/domain/iemr/QuestionOption.java | 13 ++++ .../iemr/flw/dto/iemr/FormResponseDTO.java | 2 + .../flw/dto/iemr/LatestFormVersionDTO.java | 42 +++++++++++++ .../iemr/flw/dto/iemr/QuestionOptionDTO.java | 3 + .../flw/dto/iemr/QuestionResponseDTO.java | 5 ++ .../iemr/flw/repo/iemr/FormResponseRepo.java | 2 + .../iemr/flw/repo/iemr/FormVersionRepo.java | 8 +++ .../flw/repo/iemr/QuestionOptionRepo.java | 6 ++ .../migration/V009_BackfillOptionUuid.java | 62 +++++++++++++++++++ .../service/DynamicFormDefinitionService.java | 3 + .../DynamicFormDefinitionServiceImpl.java | 10 +++ .../DynamicFormReconciliationServiceImpl.java | 5 ++ .../impl/DynamicFormResponseServiceImpl.java | 49 +++++++++++++-- .../service/impl/FormResponseItemSaver.java | 16 ++++- 15 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/iemr/flw/dto/iemr/LatestFormVersionDTO.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V009_BackfillOptionUuid.java diff --git a/src/main/java/com/iemr/flw/controller/DynamicFormController.java b/src/main/java/com/iemr/flw/controller/DynamicFormController.java index 59295cbf7..254313e93 100644 --- a/src/main/java/com/iemr/flw/controller/DynamicFormController.java +++ b/src/main/java/com/iemr/flw/controller/DynamicFormController.java @@ -22,6 +22,7 @@ package com.iemr.flw.controller; import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.LatestFormVersionDTO; import com.iemr.flw.service.DynamicFormDefinitionService; import com.iemr.flw.utils.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -35,6 +36,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * REST controller for dynamic form definition management. * All write operations use POST; all read operations use GET. @@ -97,4 +100,10 @@ public ResponseEntity deactivateForm( return ResponseEntity.ok(new ApiResponse(true, "Form deactivated", null)); } + @Operation(summary = "Get the latest version of each active form (formId, formUuid, formName, currentVersionNumber)") + @RequestMapping(value = "/getLatestFormVersions", method = RequestMethod.GET) + public ResponseEntity getLatestFormVersions() { + List result = formService.getLatestFormVersions(); + return ResponseEntity.ok(new ApiResponse(true, "Latest form versions fetched successfully", result)); + } } diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index 645fa0d1b..9a35f1a82 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -21,6 +21,7 @@ */ package com.iemr.flw.domain.iemr; +import com.iemr.flw.masterEnum.FormType; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -70,6 +71,10 @@ public class QuestionOption { @Column(name = "optionValue", nullable = false, length = 100) private String optionValue; + /** Stable across every version of a form (formType + optionValue), unlike optionId — see {@link #buildOptionUuid}. */ + @Column(name = "optionUuid", length = 255) + private String optionUuid; + @Column(name = "optionValue_hindi", length = 100) private String optionValueHindi; @@ -111,4 +116,12 @@ public class QuestionOption { @Column(name = "SyncFailureReason") private String syncFailureReason; + + /** e.g. (OCCUPATION_CONTACT_TRACING, "Yes") → "OCCUPATION_CONTACT_TRACING_YES". */ + public static String buildOptionUuid(FormType formType, String optionValue) { + String normalized = optionValue.toUpperCase() + .replaceAll("[^A-Z0-9]+", "_") + .replaceAll("^_+|_+$", ""); + return formType.name() + "_" + normalized; + } } diff --git a/src/main/java/com/iemr/flw/dto/iemr/FormResponseDTO.java b/src/main/java/com/iemr/flw/dto/iemr/FormResponseDTO.java index d53a5823a..8ab1e3903 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/FormResponseDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/FormResponseDTO.java @@ -44,6 +44,8 @@ public class FormResponseDTO { private Long beneficiaryId; private Long formId; private Long versionId; + /** Version number of the form version this response was captured against (versionId), not necessarily the latest. */ + private Integer versionNumber; private Long officerId; private String status; private String createdBy; diff --git a/src/main/java/com/iemr/flw/dto/iemr/LatestFormVersionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/LatestFormVersionDTO.java new file mode 100644 index 000000000..ee1d197e5 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/iemr/LatestFormVersionDTO.java @@ -0,0 +1,42 @@ +/* + * 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.dto.iemr; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Latest version summary of a dynamic form: identifies the form and its current version number. + * + * @author Piramal Swasthya + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LatestFormVersionDTO { + private Long formId; + private String formUuid; + private String formName; + private Long versionId; + private Integer currentVersionNumber; +} diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java index 6682404a0..4831f9579 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java @@ -51,6 +51,9 @@ public class QuestionOptionDTO { private String optionValueHindi; + /** Optional on input — derived from formType + optionValue when blank. */ + private String optionUuid; + @NotNull(message = "displayOrder is required") private Integer displayOrder; diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionResponseDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionResponseDTO.java index 03f1622e1..f3ec7b399 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionResponseDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionResponseDTO.java @@ -40,8 +40,13 @@ public class QuestionResponseDTO { private Long questionResponseId; private Long questionId; + private String questionUuid; /** Populated for RADIO, DROPDOWN, MCQ, CHECKBOX, CHECKBOX_MULTI, and DROPDOWN_MULTI answers. */ private Long optionId; + /** optionValue of the selected option; null when optionId is null. */ + private String optionValue; + /** Stable across form versions; null when optionId is null. */ + private String optionUuid; /** Populated for TEXT, DATE, AUTO_FILL, NUMBER_PICKER answers. */ private String answerText; } diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java index f389b5dec..c2abe9a0c 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java @@ -41,6 +41,8 @@ public interface FormResponseRepo extends JpaRepository { List findByBeneficiaryIdAndFormId(Long beneficiaryId, Long formId); + List findByBeneficiaryIdAndVersionId(Long beneficiaryId, Long versionId); + @Query("SELECT r FROM FormResponse r WHERE r.formId = :formId AND r.status IN :statuses") List findByFormIdAndStatusIn(@Param("formId") Long formId, @Param("statuses") List statuses); diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormVersionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormVersionRepo.java index 1d8583aea..e7a8c8ecd 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormVersionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormVersionRepo.java @@ -22,7 +22,9 @@ package com.iemr.flw.repo.iemr; import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.dto.iemr.LatestFormVersionDTO; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -43,4 +45,10 @@ public interface FormVersionRepo extends JpaRepository { List findByDynamicForm_FormIdOrderByVersionNumberAsc(Long formId); Optional findByDynamicForm_FormUuidAndIsLatest(String formUuid, boolean b); + + @Query("SELECT new com.iemr.flw.dto.iemr.LatestFormVersionDTO(f.formId, f.formUuid, f.formName, v.versionId, v.versionNumber) " + + "FROM FormVersion v JOIN v.dynamicForm f " + + "WHERE v.isLatest = true AND f.isActive = true " + + "ORDER BY f.formId ASC") + List findLatestVersionOfActiveForms(); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java index 0d8e87969..7b5b3418c 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java @@ -60,4 +60,10 @@ void shiftDisplayOrder(@Param("questionId") Long questionId, @Param("from") int @Query("SELECT o FROM QuestionOption o JOIN FETCH o.sectionQuestion " + "WHERE o.sectionQuestion.questionId IN :questionIds AND o.isActive = true ORDER BY o.displayOrder ASC") List findByQuestionIdsOrderByDisplayOrderAsc(@Param("questionIds") Collection questionIds); + + /** All options (every form, every version, inactive included) still missing an optionUuid — used by the V009 backfill. */ + @Query("SELECT o FROM QuestionOption o JOIN FETCH o.sectionQuestion q JOIN FETCH q.formSection s " + + "JOIN FETCH s.formVersion v JOIN FETCH v.dynamicForm " + + "WHERE o.optionUuid IS NULL OR o.optionUuid = ''") + List findAllMissingOptionUuid(); } diff --git a/src/main/java/com/iemr/flw/seeder/migration/V009_BackfillOptionUuid.java b/src/main/java/com/iemr/flw/seeder/migration/V009_BackfillOptionUuid.java new file mode 100644 index 000000000..bfcb384d2 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V009_BackfillOptionUuid.java @@ -0,0 +1,62 @@ +/* + * 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.QuestionOption; +import com.iemr.flw.repo.iemr.QuestionOptionRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Backfills t_question_option.optionUuid for every option of every form version created before + * the column existed. The uuid is formType + optionValue, so the same option resolves to the same + * optionUuid in v1, v2, … — letting clients map answers across versions, as with sectionUuid/questionUuid. + * Only touches rows whose optionUuid is still blank, so re-running is a no-op. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class V009_BackfillOptionUuid implements FormStructureMigration { + + private final QuestionOptionRepo optionRepo; + + @Override + public String migrationId() { + return "V009"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + List options = optionRepo.findAllMissingOptionUuid(); + for (QuestionOption option : options) { + option.setOptionUuid(QuestionOption.buildOptionUuid( + option.getSectionQuestion().getFormSection().getFormVersion().getDynamicForm().getFormType(), + option.getOptionValue())); + } + optionRepo.saveAll(options); + log.info("V009: backfilled optionUuid on {} option(s).", options.size()); + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java b/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java index ed2d92d56..b3e5558c9 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java @@ -23,6 +23,7 @@ import com.iemr.flw.dto.iemr.DynamicFormDTO; import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.LatestFormVersionDTO; import com.iemr.flw.dto.iemr.OptionConditionDTO; import com.iemr.flw.dto.iemr.QuestionOptionDTO; import com.iemr.flw.dto.iemr.QuestionValidationDTO; @@ -56,4 +57,6 @@ public interface DynamicFormDefinitionService { /** Sets the form's isActive flag to false and invalidates cache. */ void deactivateForm(Long formId); + /** Latest version of every active form: formId, formUuid, formName and current version number. */ + List getLatestFormVersions(); } 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 e61bc5c0a..1df9a2018 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java @@ -30,6 +30,7 @@ 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.LatestFormVersionDTO; import com.iemr.flw.dto.iemr.OptionConditionDTO; import com.iemr.flw.dto.iemr.QuestionOptionDTO; import com.iemr.flw.dto.iemr.QuestionValidationDTO; @@ -213,6 +214,10 @@ private void buildSectionsFromDto(List dtos, FormVersion version for (QuestionOptionDTO oDto : qDto.getOptions()) { QuestionOption opt = mapper.toEntity(oDto); opt.setSectionQuestion(savedQ); + if (opt.getOptionUuid() == null || opt.getOptionUuid().isBlank()) { + opt.setOptionUuid(QuestionOption.buildOptionUuid( + version.getDynamicForm().getFormType(), opt.getOptionValue())); + } opt.setConditions(new ArrayList<>()); QuestionOption savedOpt = optionRepo.save(opt); if (oDto.getConditions() != null && !oDto.getConditions().isEmpty()) { @@ -423,4 +428,9 @@ private SectionQuestion requireQuestion(Long questionId) { .orElseThrow(() -> new RuntimeException("Question not found: " + questionId)); } + @Override + @Transactional(readOnly = true) + public List getLatestFormVersions() { + return versionRepo.findLatestVersionOfActiveForms(); + } } diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java index d5de312bd..235093d4f 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java @@ -253,6 +253,11 @@ private QuestionOption createOption(SectionQuestion question, QuestionOptionDTO QuestionOption option = mapper.toEntity(dto); option.setOptionId(null); option.setSectionQuestion(question); + if (option.getOptionUuid() == null || option.getOptionUuid().isBlank()) { + option.setOptionUuid(QuestionOption.buildOptionUuid( + question.getFormSection().getFormVersion().getDynamicForm().getFormType(), + option.getOptionValue())); + } option.setDisplayOrder(order); option.setConditions(new ArrayList<>()); return optionRepo.save(option); diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index e163959ce..f74088a6d 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -64,6 +64,7 @@ import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -136,7 +137,7 @@ public FormResponseDTO completeForm(FormResponseRequest request, String jwtToken Timestamp now = new Timestamp(System.currentTimeMillis()); List existing = - formResponseRepo.findByBeneficiaryIdAndFormId(request.getBeneficiaryId(), formId); + formResponseRepo.findByBeneficiaryIdAndVersionId(request.getBeneficiaryId(), version.getVersionId()); FormResponse formResponse = existing.isEmpty() ? createFormResponse(request, version, FormResponseStatus.SUBMITTED.name(), now, vanID, parkingPlaceID) : existing.get(0); @@ -164,8 +165,21 @@ public List getResponsesByBeneficiary(Long beneficiaryId, Integ List responses = beneficiaryId != null ? formResponseRepo.findByBeneficiaryIdAndFormId(beneficiaryId, formId) : formResponseRepo.findByFormIdFiltered(formId, villageId, providerServiceMapId); + + // Responses may belong to older versions than the latest one resolved above. + Set versionIds = responses.stream() + .map(FormResponse::getVersionId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map versionNumberById = formVersionRepo.findAllById(versionIds).stream() + .collect(Collectors.toMap(FormVersion::getVersionId, FormVersion::getVersionNumber)); + return responses.stream() - .map(r -> buildFormResponseDTO(r, loadSectionResponseDTOs(r.getResponseId()))) + .map(r -> { + FormResponseDTO dto = buildFormResponseDTO(r, loadSectionResponseDTOs(r.getResponseId())); + dto.setVersionNumber(versionNumberById.get(r.getVersionId())); + return dto; + }) .collect(Collectors.toList()); } @@ -244,8 +258,12 @@ private FormResponseDTO processSections( Set questionIds = allQuestions.stream() .map(SectionQuestion::getQuestionId) .collect(Collectors.toSet()); + Map questionUuidById = allQuestions.stream() + .collect(Collectors.toMap(SectionQuestion::getQuestionId, SectionQuestion::getQuestionUuid)); List allOptions = questionOptionRepo.findByQuestionIdsOrderByDisplayOrderAsc(questionIds); + Map optionById = allOptions.stream() + .collect(Collectors.toMap(QuestionOption::getOptionId, Function.identity())); // Map: questionId → (optionValue → QuestionOption) Map> optionsByQuestion = allOptions.stream() @@ -299,7 +317,7 @@ private FormResponseDTO processSections( questionResponseRepo.updateVanSerialNo(qr.getQuestionResponseId(), qr.getQuestionResponseId()); } - sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses)); + sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses, questionUuidById, optionById)); } return buildFormResponseDTO(formResponse, sectionDTOs); @@ -461,11 +479,26 @@ private List loadSectionResponseDTOs(Long responseId) { Map sectionById = formSectionRepo.findAllById(sectionIds).stream() .collect(Collectors.toMap(FormSection::getSectionId, Function.identity())); + // findAllById includes unlinked (isActive=false) questions, so historical answers still resolve their uuid. + Set questionIds = allAnswers.stream() + .map(QuestionResponse::getQuestionId) + .collect(Collectors.toSet()); + Map questionUuidById = sectionQuestionRepo.findAllById(questionIds).stream() + .collect(Collectors.toMap(SectionQuestion::getQuestionId, SectionQuestion::getQuestionUuid)); + Set optionIds = allAnswers.stream() + .map(QuestionResponse::getOptionId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map optionById = questionOptionRepo.findAllById(optionIds).stream() + .collect(Collectors.toMap(QuestionOption::getOptionId, Function.identity())); + return sections.stream() .map(sr -> buildSectionResponseDTO( sr, sectionById.get(sr.getSectionId()), - answersBySectionResponse.getOrDefault(sr.getSectionResponseId(), List.of()))) + answersBySectionResponse.getOrDefault(sr.getSectionResponseId(), List.of()), + questionUuidById, + optionById)) .collect(Collectors.toList()); } @@ -489,12 +522,18 @@ private FormResponseDTO buildFormResponseDTO(FormResponse r, List answers) { + SectionResponse sr, FormSection section, List answers, + Map questionUuidById, Map optionById) { List answerDTOs = answers.stream() .map(a -> QuestionResponseDTO.builder() .questionResponseId(a.getQuestionResponseId()) .questionId(a.getQuestionId()) + .questionUuid(questionUuidById.get(a.getQuestionId())) .optionId(a.getOptionId()) + .optionValue(optionById.containsKey(a.getOptionId()) + ? optionById.get(a.getOptionId()).getOptionValue() : null) + .optionUuid(optionById.containsKey(a.getOptionId()) + ? optionById.get(a.getOptionId()).getOptionUuid() : null) .answerText(a.getAnswerText()) .build()) .collect(Collectors.toList()); diff --git a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java index f03398c5a..9a6e04877 100644 --- a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java +++ b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java @@ -112,7 +112,7 @@ public FormResponseDTO saveForBulk(FormResponseRequest req, String jwtToken) thr // Step 2: Check for an existing response for this beneficiary+form List existing = - formResponseRepo.findByBeneficiaryIdAndFormId(req.getBeneficiaryId(), formId); + formResponseRepo.findByBeneficiaryIdAndVersionId(req.getBeneficiaryId(), version.getVersionId()); FormResponse formResponse; if (!existing.isEmpty()) { @@ -221,8 +221,12 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR Set questionIds = allQuestions.stream() .map(SectionQuestion::getQuestionId) .collect(Collectors.toSet()); + Map questionUuidById = allQuestions.stream() + .collect(Collectors.toMap(SectionQuestion::getQuestionId, SectionQuestion::getQuestionUuid)); List allOptions = questionOptionRepo.findByQuestionIdsOrderByDisplayOrderAsc(questionIds); + Map optionById = allOptions.stream() + .collect(Collectors.toMap(QuestionOption::getOptionId, Function.identity())); Map> optionsByQuestion = allOptions.stream() .collect(Collectors.groupingBy( o -> o.getSectionQuestion().getQuestionId(), @@ -271,7 +275,7 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR for (QuestionResponse qr : questionResponses) { questionResponseRepo.updateVanSerialNo(qr.getQuestionResponseId(), qr.getQuestionResponseId()); } - sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses)); + sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses, questionUuidById, optionById)); } return buildFormResponseDTO(formResponse, sectionDTOs); @@ -425,12 +429,18 @@ private FormResponseDTO buildFormResponseDTO(FormResponse r, List answers) { + SectionResponse sr, FormSection section, List answers, + Map questionUuidById, Map optionById) { List answerDTOs = answers.stream() .map(a -> QuestionResponseDTO.builder() .questionResponseId(a.getQuestionResponseId()) .questionId(a.getQuestionId()) + .questionUuid(questionUuidById.get(a.getQuestionId())) .optionId(a.getOptionId()) + .optionValue(optionById.containsKey(a.getOptionId()) + ? optionById.get(a.getOptionId()).getOptionValue() : null) + .optionUuid(optionById.containsKey(a.getOptionId()) + ? optionById.get(a.getOptionId()).getOptionUuid() : null) .answerText(a.getAnswerText()) .build()) .collect(Collectors.toList()); From dee56117b74c1371db4496c06b280997316f8e8f Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 6 Oct 2026 10:00:16 +0530 Subject: [PATCH 23/87] fix(diagnostic): stop the poll scheduler from nulling vanSerialNo pollOnce saved a stale copy of the order loaded before createAndPushOrder's updateVanSerialNo() ran, writing vanSerialNo back to NULL. MMU sync keys tb_diagnostic_order by vanSerialNo, so those rows could never sync and the Stop TB sync group stayed partial. - refill vanSerialNo after the scheduler's save in pollOnce - mark vanSerialNo updatable=false on the diagnostic entities so entity saves can no longer overwrite it (updateVanSerialNo still sets it) Co-Authored-By: Claude Opus 5.5 --- .../java/com/iemr/flw/domain/iemr/DiagnosticDocument.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java | 2 +- .../com/iemr/flw/domain/iemr/DiagnosticProviderToken.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java | 2 +- .../com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java | 4 +++- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java index d4e78747f..5afd367ac 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java @@ -84,6 +84,6 @@ public class DiagnosticDocument { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java index 197999fe2..aa89012eb 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -101,6 +101,6 @@ public class DiagnosticOrder { @Column(name = "processed") private String processed = "N"; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java index 8ef233060..da8e23ddc 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java @@ -46,6 +46,6 @@ public class DiagnosticProviderToken { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java index 6cb9fba6b..55bce475a 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java @@ -70,6 +70,6 @@ public class DiagnosticResult { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java b/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java index f9abde570..e81b553d3 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java +++ b/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java @@ -135,6 +135,6 @@ public class StopTBDiagnostics { @Column(name = "processed") private String processed = "N"; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } 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..adf64d851 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -338,7 +338,9 @@ 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())); - diagnosticOrderRepo.save(order); + order = diagnosticOrderRepo.save(order); + // The scheduler's copy may predate createAndPushOrder's updateVanSerialNo(); fill it if still missing. + if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); return null; } DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); From 290265f87580fb1eea39e265cccf7912b6bc74b4 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 6 Oct 2026 10:35:02 +0530 Subject: [PATCH 24/87] fix(diagnostic): stamp vanSerialNo right after the order is first saved Fill vanSerialNo before the (possibly slow) EMR Lite push instead of only after it, so a crash or restart mid-push can't leave the order without its sync key. Same value (= id); no change to status, push or response. Co-Authored-By: Claude Opus 5.5 --- .../iemr/flw/service/impl/DiagnosticOrderServiceImpl.java | 5 +++++ 1 file changed, 5 insertions(+) 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 adf64d851..d8dfc6abc 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -136,6 +136,11 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr } throw dive; } + // Stamp the sync key before the (possibly slow) vendor push, so a crash/restart mid-push can't leave it NULL. + if (order.getVanSerialNo() == null) { + diagnosticOrderRepo.updateVanSerialNo(order.getId()); + order.setVanSerialNo(order.getId()); + } if (providerCode == null || providerCode.isBlank()) { logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry", From 50dc8bad084561fdebfe35b9f8f30627ddbe6a10 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Tue, 6 Oct 2026 18:33:54 +0530 Subject: [PATCH 25/87] feat: stop-542 extra fields consumer changes --- pom.xml | 264 +++++++++--------- .../environment/common_example.properties | 47 ++-- .../service/impl/BeneficiaryServiceImpl.java | 40 +-- .../flw/service/impl/StopTBServiceImpl.java | 28 +- 4 files changed, 204 insertions(+), 175 deletions(-) diff --git a/pom.xml b/pom.xml index 5fc0bc731..d7ba21d7e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,39 +1,39 @@ - 4.0.0 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 - com.iemr.common.flw - flw-api - 3.12.0 - war + com.iemr.common.flw + flw-api + 3.12.0 + war - FLW-API - Iemr Flw Service + FLW-API + Iemr Flw Service - - org.springframework.boot - spring-boot-starter-parent - 3.2.2 - - - - UTF-8 - UTF-8 - ${maven.build.timestamp} - yyyy-MM-dd HH:mm - 17 - jdt_apt - 1.2.0.Final - 1.16.18 - ${ENV_VAR} - target/classes/application.properties - target/classes/common_${environment}.properties - true - target - - + + org.springframework.boot + spring-boot-starter-parent + 3.2.2 + + + + UTF-8 + UTF-8 + ${maven.build.timestamp} + yyyy-MM-dd HH:mm + 17 + jdt_apt + 1.2.0.Final + 1.16.18 + example + target/classes/application.properties + target/classes/common_${environment}.properties + true + target + + org.springframework.boot spring-boot-starter-data-jpa @@ -56,10 +56,10 @@ org.springframework.boot spring-boot-starter - - co.elastic.logging - logback-ecs-encoder - 1.3.2 + + co.elastic.logging + logback-ecs-encoder + 1.3.2 org.springframework.boot @@ -234,10 +234,10 @@ tomcat-jdbc - org.modelmapper - modelmapper - 2.4.4 - + org.modelmapper + modelmapper + 2.4.4 + @@ -261,56 +261,56 @@ - - - 1097_flw - - 1097flwapi-v1.0 - target/classes/application.properties - src/main/environment/1097_${environment}.properties - - - 1097flwapi-v1.0 - - - + + + 1097_flw + + 1097flwapi-v1.0 + target/classes/application.properties + src/main/environment/1097_${environment}.properties + + + 1097flwapi-v1.0 + + + - - ${artifactId}-${version} - - - - org.apache.maven.plugins - maven-jar-plugin - 3.0.2 - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - 3.7.0 - - ${java.version} - ${java.version} - - - org.projectlombok - lombok - ${lombok.version} - - - org.mapstruct - mapstruct-processor - ${org.mapstruct.version} - - - - + + ${artifactId}-${version} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.0.2 + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + + org.apache.maven.plugins maven-resources-plugin @@ -363,31 +363,31 @@ false - - org.apache.maven.plugins - maven-antrun-plugin - 1.8 - - - process-resources - - run - - properties-updated - - - - concatinating properties file ${target-properties} and - ${source-properties} - - - - - - - - + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + process-resources + + run + + properties-updated + + + + concatinating properties file ${target-properties} and + ${source-properties} + + + + + + + + process-resources @@ -402,16 +402,16 @@ - - - - org.apache.maven.plugins - maven-surefire-plugin - - true - - - @@ -261,56 +261,56 @@ - - - 1097_flw - - 1097flwapi-v1.0 - target/classes/application.properties - src/main/environment/1097_${environment}.properties - - - 1097flwapi-v1.0 - - - - + + + 1097_flw + + 1097flwapi-v1.0 + target/classes/application.properties + src/main/environment/1097_${environment}.properties + + + 1097flwapi-v1.0 + + + - - ${artifactId}-${version} - - - org.apache.maven.plugins - maven-jar-plugin - 3.0.2 - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - 3.7.0 - - ${java.version} - ${java.version} - - - org.projectlombok - lombok - ${lombok.version} - - - org.mapstruct - mapstruct-processor - ${org.mapstruct.version} - - - - + + ${artifactId}-${version} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.0.2 + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + + org.apache.maven.plugins maven-resources-plugin @@ -363,31 +363,31 @@ false - - org.apache.maven.plugins - maven-antrun-plugin - 1.8 - - - process-resources - - run - - properties-updated - - - - concatinating properties file ${target-properties} and - ${source-properties} - - - - - - - - + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + process-resources + + run + + properties-updated + + + + concatinating properties file ${target-properties} and + ${source-properties} + + + + + + + + process-resources @@ -402,16 +402,16 @@ - - - - org.apache.maven.plugins - maven-surefire-plugin - - true - - - @@ -261,56 +261,56 @@ - - - 1097_flw - - 1097flwapi-v1.0 - target/classes/application.properties - src/main/environment/1097_${environment}.properties - - - 1097flwapi-v1.0 - - - + + + 1097_flw + + 1097flwapi-v1.0 + target/classes/application.properties + src/main/environment/1097_${environment}.properties + + + 1097flwapi-v1.0 + + + - - ${artifactId}-${version} - - - - org.apache.maven.plugins - maven-jar-plugin - 3.0.2 - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - 3.7.0 - - ${java.version} - ${java.version} - - - org.projectlombok - lombok - ${lombok.version} - - - org.mapstruct - mapstruct-processor - ${org.mapstruct.version} - - - - + + ${artifactId}-${version} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.0.2 + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + + org.apache.maven.plugins maven-resources-plugin @@ -363,31 +363,31 @@ false - - org.apache.maven.plugins - maven-antrun-plugin - 1.8 - - - process-resources - - run - - properties-updated - - - - concatinating properties file ${target-properties} and - ${source-properties} - - - - - - - - + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + process-resources + + run + + properties-updated + + + + concatinating properties file ${target-properties} and + ${source-properties} + + + + + + + + process-resources @@ -402,16 +402,16 @@ - - - - org.apache.maven.plugins - maven-surefire-plugin - - true - - - @@ -261,56 +261,56 @@ - - - 1097_flw - - 1097flwapi-v1.0 - target/classes/application.properties - src/main/environment/1097_${environment}.properties - - - 1097flwapi-v1.0 - - - - + + + 1097_flw + + 1097flwapi-v1.0 + target/classes/application.properties + src/main/environment/1097_${environment}.properties + + + 1097flwapi-v1.0 + + + - - ${artifactId}-${version} - - - org.apache.maven.plugins - maven-jar-plugin - 3.0.2 - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - 3.7.0 - - ${java.version} - ${java.version} - - - org.projectlombok - lombok - ${lombok.version} - - - org.mapstruct - mapstruct-processor - ${org.mapstruct.version} - - - - + + ${artifactId}-${version} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.0.2 + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + + org.apache.maven.plugins maven-resources-plugin @@ -363,31 +363,31 @@ false - - org.apache.maven.plugins - maven-antrun-plugin - 1.8 - - - process-resources - - run - - properties-updated - - - - concatinating properties file ${target-properties} and - ${source-properties} - - - - - - - - + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + process-resources + + run + + properties-updated + + + + concatinating properties file ${target-properties} and + ${source-properties} + + + + + + + + process-resources @@ -402,16 +402,16 @@ - - - - org.apache.maven.plugins - maven-surefire-plugin - - true - - -