From bee2f14419fb5ae1e70e752eb143f376722400f7 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 13 Oct 2026 12:10:25 +0530 Subject: [PATCH 01/33] Adding locking for multiple order creation --- .../emrlite/EmrLiteRestTemplateConfig.java | 6 ++++ .../impl/DiagnosticOrderServiceImpl.java | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java index 87079a18..53494b68 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java @@ -14,10 +14,16 @@ public class EmrLiteRestTemplateConfig { private static final int VENDOR_CALL_TIMEOUT_MS = 20000; + // Result responses carry document assets inline as base64, so reads get more headroom than connects. + private static final int VENDOR_READ_TIMEOUT_MS = 60000; + // Timeouts are required, not optional: order pushes run while holding the per-beneficiary+orderType + // order lock (see DiagnosticOrderServiceImpl), so a hung vendor call without them would hold that lock forever. @Bean public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(VENDOR_CALL_TIMEOUT_MS); + factory.setReadTimeout(VENDOR_READ_TIMEOUT_MS); RestTemplate restTemplate = new RestTemplate(factory); restTemplate.getInterceptors().add(new EmrLiteAuthInterceptor(emrLiteTokenManager)); return restTemplate; 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 d8dfc6ab..8c93e055 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -21,6 +21,9 @@ import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.DiagnosticDocumentService; import com.iemr.flw.service.DiagnosticOrderService; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +34,10 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; @Service public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @@ -62,8 +69,36 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @Autowired private CampConfigService campConfigService; + private record OrderLockKey(Long beneficiaryId, String orderType) {} + private final LoadingCache orderLocks = CacheBuilder.newBuilder() + .weakValues() + .build(CacheLoader.from(key -> new ReentrantLock())); + + private static final long LOCK_WAIT_SECONDS = 30; + + private T withOrderLock(Long beneficiaryId, String orderType, Callable action) throws Exception { + if (beneficiaryId == null || orderType == null) { + throw new IllegalArgumentException("beneficiaryId and orderType are required"); + } + Lock lock = orderLocks.getUnchecked(new OrderLockKey(beneficiaryId, orderType)); + if (!lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("Another diagnostic order operation is still in progress for beneficiaryId=" + + beneficiaryId + ", orderType=" + orderType + " — please retry shortly"); + } + try { + return action.call(); + } finally { + lock.unlock(); + } + } + @Override public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { + DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); + return withOrderLock(request.getBeneficiaryId(), orderType.name(), () -> createAndPushOrderLocked(request)); + } + + private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto request) throws Exception { Long beneficiaryId = request.getBeneficiaryId(); Long visitCode = request.getVisitCode(); DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); From 8d18e8d6cf02df5a29e3533cd6894437513e0d95 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 09:15:25 +0530 Subject: [PATCH 02/33] feat(stoptb): save OPD prescription to standard tables and dispense stock Same endpoint stoptb/nurse/generalOpd/save. When the app sends the new fields (submissionId, chiefComplaints[], prescription.drugs[] with drugID and qtyPrescribed) the save writes t_benchiefcomplaint, t_prescription, t_prescribeddrug, t_patientissue and t_itemstockexit and reduces t_itemstockentry from the van's store, like TM and Inventory-API. One transaction: insufficient stock rejects the whole submit. submissionId makes retries safe. Old payload behaves as before. The configured stoptb.van.id is the source of truth; a different vanID from the app is rejected. Parking place and store are read from m_van. Co-Authored-By: Claude Opus 5.5 --- .../iemr/flw/controller/StopTBController.java | 5 + .../iemr/flw/service/CampConfigService.java | 65 ++- .../service/StopTBPrescriptionService.java | 383 ++++++++++++++++++ .../flw/service/impl/StopTBServiceImpl.java | 62 ++- 4 files changed, 502 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java diff --git a/src/main/java/com/iemr/flw/controller/StopTBController.java b/src/main/java/com/iemr/flw/controller/StopTBController.java index 7f73d29e..49cb691b 100644 --- a/src/main/java/com/iemr/flw/controller/StopTBController.java +++ b/src/main/java/com/iemr/flw/controller/StopTBController.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.iemr.flw.service.StopTBPrescriptionService; import com.iemr.flw.service.StopTBService; import com.iemr.flw.utils.response.OutputResponse; import io.swagger.v3.oas.annotations.Operation; @@ -101,6 +102,10 @@ public String saveGeneralOpd(@RequestBody List> dataList) { try { List> result = stopTBService.saveGeneralOpd(dataList); response.setResponse(gson.toJson(result)); + } catch (StopTBPrescriptionService.InsufficientStockException e) { + // Nothing was saved; the app shows this and lets the nurse lower the quantity. + logger.warn("saveGeneralOpd rejected: " + e.getMessage()); + response.setError(5000, e.getMessage()); } catch (Exception e) { logger.error("Error in saveGeneralOpd: " + e); response.setError(5000, "Error saving general OPD: " + e.getMessage()); diff --git a/src/main/java/com/iemr/flw/service/CampConfigService.java b/src/main/java/com/iemr/flw/service/CampConfigService.java index 305bf1f0..11cf2340 100644 --- a/src/main/java/com/iemr/flw/service/CampConfigService.java +++ b/src/main/java/com/iemr/flw/service/CampConfigService.java @@ -1,8 +1,14 @@ package com.iemr.flw.service; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.List; + /** * Camp/van identity for this deployment. * @@ -17,12 +23,14 @@ * No inline default — every properties file must set stoptb.van.id explicitly, so a forgotten * config fails loudly at startup instead of silently running unconfigured. * - * Scope: vanID (and the VanSerialNo stamping that depends on it) only. parkingPlaceID is out of - * scope for this change. + * The van's parking place and store (facility) come from m_van, the same source MMU uses + * (MasterVanRepo.getFacilityID), and are read once and cached since the van never changes. */ @Service public class CampConfigService { + private final Logger logger = LoggerFactory.getLogger(CampConfigService.class); + @Value("${stoptb.van.id}") private int vanID; @@ -31,6 +39,12 @@ public class CampConfigService { @Value("${stoptb.enforce.vanid}") private boolean enforceVanID; + @PersistenceContext(unitName = "db_iemr") + private EntityManager entityManager; + + private volatile Integer parkingPlaceID; + private volatile Integer facilityID; + public Integer getVanID() { if (vanID <= 0) { if (enforceVanID) { @@ -42,13 +56,54 @@ public Integer getVanID() { return vanID; } + /** + * The configured van is the source of truth. A vanID sent by the app is only checked against it: + * missing -> configured van; same -> OK; different -> rejected, so data and stock never land on + * another camp's van/store. + */ + public Integer resolveVanID(Integer requestedVanID) { + Integer configured = getVanID(); + if (requestedVanID != null && requestedVanID > 0 && configured != null + && !requestedVanID.equals(configured)) { + throw new IllegalArgumentException("vanID " + requestedVanID + + " does not match this camp's van " + configured); + } + return configured; + } + public boolean isCampConfigured() { return vanID > 0; } - // Not sourced from Redis anymore (out of scope for this change) — kept only because - // callers across StopTBServiceImpl/DiagnosticOrderServiceImpl/etc. still call it. + // Parking place of the configured van (m_van.ParkingPlaceID); 0 when the camp is not configured + // or the van is not found, which is what callers received before this lookup existed. public Integer getParkingPlaceID() { - return 0; + if (parkingPlaceID == null) loadVanDetails(); + return parkingPlaceID != null ? parkingPlaceID : 0; + } + + // Store mapped to the configured van (m_van.FacilityID); null when no store is mapped. + public Integer getFacilityID() { + if (facilityID == null) loadVanDetails(); + return facilityID; + } + + private synchronized void loadVanDetails() { + if (!isCampConfigured() || (parkingPlaceID != null && facilityID != null)) return; + try { + List rows = entityManager.createNativeQuery( + "SELECT ParkingPlaceID, FacilityID FROM db_iemr.m_van WHERE VanID = :vanID AND Deleted = false") + .setParameter("vanID", vanID) + .getResultList(); + if (rows.isEmpty()) { + logger.warn("stoptb.van.id " + vanID + " not found in m_van"); + return; + } + Object[] row = (Object[]) rows.get(0); + parkingPlaceID = row[0] != null ? ((Number) row[0]).intValue() : null; + facilityID = row[1] != null ? ((Number) row[1]).intValue() : null; + } catch (Exception e) { + logger.error("Cannot read m_van details for vanID " + vanID, e); + } } } diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java new file mode 100644 index 00000000..061b1ba7 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -0,0 +1,383 @@ +package com.iemr.flw.service; + +import com.iemr.flw.domain.iemr.BenVisitDetail; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * Stop TB nurse OPD: chief complaints, prescription and dispensing written to the same standard + * tables MMU/TM and Inventory-API use, inside the caller's transaction. + * + * - Chief complaints -> t_benchiefcomplaint (one row per complaint, like TM saveBenChiefComplaints) + * - Prescription -> t_prescription + t_prescribeddrug (DrugID + QtyPrescribed, like TM doctor save) + * - Dispensing -> t_patientissue + t_itemstockexit (ExitType T_PatientIssue) and + * t_itemstockentry.QuantityInHand reduced, like Inventory-API issuePatientDrugs. + * + * Batches are picked by m_itemcategory.IssueType (FEFO / FIFO / LIFO) from non-expired batches that + * last the treatment duration, and are locked (SELECT ... FOR UPDATE) so two nurses can never take the + * same stock. If any drug is short, nothing is saved (InsufficientStockException rolls back the caller). + * + * Native SQL only, no new entity mappings: FLW runs with ddl-auto=create. + */ +@Service +public class StopTBPrescriptionService { + + private static final String REFERENCE_PREFIX = "StopTB:"; + + @PersistenceContext(unitName = "db_iemr") + private EntityManager em; + + @Autowired + private CampConfigService campConfigService; + + public static class InsufficientStockException extends RuntimeException { + public InsufficientStockException(String message) { + super(message); + } + } + + /** Previous result for a submissionId already saved, or null. Lets the app retry safely. */ + @Transactional(propagation = Propagation.MANDATORY) + public Map findPreviousSubmission(Long beneficiaryRegID, String submissionId) { + if (submissionId == null || submissionId.isBlank()) return null; + List rows = em.createNativeQuery("SELECT PatientIssueID, VisitCode, PrescriptionID FROM db_iemr.t_patientissue " + + "WHERE BeneficiaryRegID = :ben AND Reference = :ref AND Deleted = false") + .setParameter("ben", beneficiaryRegID) + .setParameter("ref", REFERENCE_PREFIX + submissionId) + .getResultList(); + if (rows.isEmpty()) return null; + Object[] row = (Object[]) rows.get(0); + Integer facilityID = campConfigService.getFacilityID(); + + List issued = em.createNativeQuery("SELECT e.ItemID, i.ItemName, SUM(x.Quantity) " + + "FROM db_iemr.t_itemstockexit x " + + "JOIN db_iemr.t_itemstockentry e ON e.VanSerialNo = x.ItemStockEntryID AND e.FacilityID = x.FacilityID " + + "JOIN db_iemr.m_item i ON i.ItemID = e.ItemID " + + "WHERE x.ExitType = 'T_PatientIssue' AND x.ExitTypeID = :issueID AND x.FacilityID = :facilityID " + + "GROUP BY e.ItemID, i.ItemName") + .setParameter("issueID", ((Number) row[0]).longValue()) + .setParameter("facilityID", facilityID) + .getResultList(); + List> dispensed = new ArrayList<>(); + for (Object o : issued) { + Object[] r = (Object[]) o; + int drugID = ((Number) r[0]).intValue(); + Map d = new LinkedHashMap<>(); + d.put("drugID", drugID); + d.put("drugName", r[1]); + d.put("qtyIssued", ((Number) r[2]).intValue()); + d.put("availableQuantity", availableQuantity(facilityID, drugID, 0)); + dispensed.add(d); + } + Map result = new LinkedHashMap<>(); + result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", row[1] != null ? ((Number) row[1]).longValue() : null); + result.put("prescriptionID", row[2] != null ? ((Number) row[2]).longValue() : null); + result.put("dispensed", dispensed); + result.put("duplicate", true); + return result; + } + + /** + * Replaces the visit's chief complaints with the given list (edit = remove old + insert new, as MMU + * does on update). Rows without an ID and without a name are skipped. + */ + @Transactional(propagation = Propagation.MANDATORY) + public void saveChiefComplaints(List> complaints, Long beneficiaryRegID, BenVisitDetail visit, + String createdBy, Integer vanID, Integer parkingPlaceID) { + if (complaints == null) return; + em.createNativeQuery("UPDATE db_iemr.t_benchiefcomplaint SET Deleted = true, ModifiedBy = :user " + + "WHERE BeneficiaryRegID = :ben AND VisitCode = :visitCode AND Deleted = false") + .setParameter("user", createdBy) + .setParameter("ben", beneficiaryRegID) + .setParameter("visitCode", visit.getVisitCode()) + .executeUpdate(); + for (Map c : complaints) { + Integer complaintID = toInt(c.get("chiefComplaintID")); + String complaint = toStr(c.get("chiefComplaint")); + if (complaintID == null && (complaint == null || complaint.isBlank())) continue; + em.createNativeQuery("INSERT INTO db_iemr.t_benchiefcomplaint (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, " + + "VisitCode, ChiefComplaintID, ChiefComplaint, Duration, UnitOfDuration, Description, Deleted, " + + "Processed, CreatedBy, VanID, ParkingPlaceID) VALUES (:ben, :visitID, :psm, :visitCode, :cid, " + + ":name, :duration, :unit, :description, false, 'N', :user, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("psm", visit.getProviderServiceMapID()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("cid", complaintID) + .setParameter("name", complaint) + .setParameter("duration", toInt(c.get("duration"))) + .setParameter("unit", toStr(c.get("unitOfDuration"))) + .setParameter("description", toStr(c.get("description"))) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + stampVanSerialNo("t_benchiefcomplaint", "ID", lastInsertId()); + } + } + + /** + * Saves the prescription and issues every drug from the camp store. Returns prescriptionID and, + * per drug, the quantity issued and what is left in the store. + */ + @Transactional(propagation = Propagation.MANDATORY) + public Map prescribeAndDispense(Map prescription, Long beneficiaryRegID, + BenVisitDetail visit, String submissionId, String createdBy, Integer vanID, Integer parkingPlaceID) { + List> drugs = toList(prescription.get("drugs")); + Map result = new LinkedHashMap<>(); + if (drugs.isEmpty()) return result; + + Integer facilityID = campConfigService.getFacilityID(); + if (facilityID == null) { + throw new IllegalStateException("No store is mapped to van " + vanID + " (m_van.FacilityID)"); + } + + // 1. Validate every line and check total stock per drug before writing anything. + Map requested = new LinkedHashMap<>(); + Map items = new HashMap<>(); + for (Map d : drugs) { + Integer drugID = toInt(d.get("drugID")); + Integer qty = toInt(d.get("qtyPrescribed")); + if (drugID == null) throw new IllegalArgumentException("drugID is required for every drug"); + if (qty == null || qty <= 0) throw new IllegalArgumentException("qtyPrescribed must be more than 0 for drugID " + drugID); + if (!items.containsKey(drugID)) items.put(drugID, getStoreItem(facilityID, drugID)); + requested.merge(drugID, qty, Integer::sum); + } + List shortages = new ArrayList<>(); + Map> lockedBatches = new HashMap<>(); + for (Map.Entry r : requested.entrySet()) { + Object[] item = items.get(r.getKey()); + int durationDays = maxDurationDays(drugs, r.getKey()); + List batches = lockBatches(facilityID, r.getKey(), (String) item[6], durationDays); + lockedBatches.put(r.getKey(), batches); + int available = batches.stream().mapToInt(b -> ((Number) b[1]).intValue()).sum(); + if (available < r.getValue()) { + shortages.add(item[1] + " (drugID " + r.getKey() + "): asked " + r.getValue() + ", available " + available); + } + } + if (!shortages.isEmpty()) { + throw new InsufficientStockException("Insufficient stock - " + String.join("; ", shortages)); + } + + // 2. Prescription + prescribed drugs (Stop TB service line, same as the visit). + Integer storePsm = getStorePsm(facilityID); + em.createNativeQuery("INSERT INTO db_iemr.t_prescription (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, VisitCode, " + + "Instruction, Deleted, Processed, CreatedBy, VanID, ParkingPlaceID) " + + "VALUES (:ben, :visitID, :psm, :visitCode, :instruction, false, 'N', :user, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("psm", visit.getProviderServiceMapID()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("instruction", toStr(prescription.get("instruction"))) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + long prescriptionID = lastInsertId(); + stampVanSerialNo("t_prescription", "PrescriptionID", prescriptionID); + + for (Map d : drugs) { + Integer drugID = toInt(d.get("drugID")); + Object[] item = items.get(drugID); + em.createNativeQuery("INSERT INTO db_iemr.t_prescribeddrug (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, " + + "VisitCode, PrescriptionID, DrugForm, DrugID, GenericDrugName, DrugStrength, Dose, Route, Frequency, " + + "Duration, DuartionUnit, SpecialInstruction, QtyPrescribed, isEDL, Deleted, Processed, CreatedBy, " + + "VanID, ParkingPlaceID) VALUES (:ben, :visitID, :psm, :visitCode, :prescriptionID, :form, :drugID, " + + ":name, :strength, :dose, :route, :frequency, :duration, :durationUnit, :instruction, :qty, " + + ":isEDL, false, 'N', :user, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("psm", visit.getProviderServiceMapID()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("prescriptionID", prescriptionID) + .setParameter("form", item[3]) + .setParameter("drugID", drugID) + .setParameter("name", item[1]) + .setParameter("strength", item[2]) + .setParameter("dose", toStr(d.get("dose"))) + .setParameter("route", item[4]) + .setParameter("frequency", toStr(d.get("frequency"))) + .setParameter("duration", toStr(d.get("duration"))) + .setParameter("durationUnit", toStr(d.get("durationUnit"))) + .setParameter("instruction", toStr(d.get("instructions"))) + .setParameter("qty", toInt(d.get("qtyPrescribed"))) + .setParameter("isEDL", item[5]) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + stampVanSerialNo("t_prescribeddrug", "PrescribedDrugID", lastInsertId()); + } + + // 3. Patient issue header (same shape as Inventory-API issuePatientDrugs, IssueType 'System'). + em.createNativeQuery("INSERT INTO db_iemr.t_patientissue (BeneficiaryRegID, BenVisitID, VisitCode, FacilityID, " + + "PrescriptionID, Reference, IssueType, IssuedBy, ProviderServiceMapID, Deleted, Processed, CreatedBy, " + + "SyncFacilityID, VanID, ParkingPlaceID) VALUES (:ben, :visitID, :visitCode, :facilityID, " + + ":prescriptionID, :ref, 'System', :user, :psm, false, 'N', :user, :facilityID, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("facilityID", facilityID) + .setParameter("prescriptionID", prescriptionID) + .setParameter("ref", submissionId != null && !submissionId.isBlank() ? REFERENCE_PREFIX + submissionId : null) + .setParameter("user", createdBy) + .setParameter("psm", storePsm) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + long patientIssueID = lastInsertId(); + stampVanSerialNo("t_patientissue", "PatientIssueID", patientIssueID); + + // 4. Take each drug from its locked batches in issue order; one stock exit per batch used. + List> dispensed = new ArrayList<>(); + for (Map.Entry r : requested.entrySet()) { + int remaining = r.getValue(); + for (Object[] batch : lockedBatches.get(r.getKey())) { + if (remaining == 0) break; + long batchSerialNo = ((Number) batch[0]).longValue(); + int take = Math.min(remaining, ((Number) batch[1]).intValue()); + em.createNativeQuery("INSERT INTO db_iemr.t_itemstockexit (ItemStockEntryID, FacilityID, Quantity, " + + "ProviderServiceMapID, ExitTypeID, ExitType, Deleted, Processed, CreatedBy, SyncFacilityID, " + + "VanID, ParkingPlaceID) VALUES (:batch, :facilityID, :qty, :psm, :issueID, 'T_PatientIssue', " + + "false, 'N', :user, :facilityID, :van, :pp)") + .setParameter("batch", batchSerialNo) + .setParameter("facilityID", facilityID) + .setParameter("qty", take) + .setParameter("psm", storePsm) + .setParameter("issueID", patientIssueID) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + stampVanSerialNo("t_itemstockexit", "ItemStockExitID", lastInsertId()); + // Same update as Inventory-API ItemStockEntryRepo.updateStock + em.createNativeQuery("UPDATE db_iemr.t_itemstockentry SET QuantityInHand = QuantityInHand - :qty " + + "WHERE VanSerialNo = :batch AND FacilityID = :facilityID") + .setParameter("qty", take) + .setParameter("batch", batchSerialNo) + .setParameter("facilityID", facilityID) + .executeUpdate(); + remaining -= take; + } + Map d = new LinkedHashMap<>(); + d.put("drugID", r.getKey()); + d.put("drugName", items.get(r.getKey())[1]); + d.put("qtyIssued", r.getValue()); + d.put("availableQuantity", availableQuantity(facilityID, r.getKey(), 0)); + dispensed.add(d); + } + + result.put("prescriptionID", prescriptionID); + result.put("dispensed", dispensed); + return result; + } + + // ItemID, ItemName, strength+UOM, form, route, isEDL, IssueType — only if the drug is mapped to the store. + private Object[] getStoreItem(Integer facilityID, Integer drugID) { + List rows = em.createNativeQuery("SELECT i.ItemID, i.ItemName, CONCAT_WS(' ', i.Strength, u.UOMName), " + + "f.ItemFormName, r.RouteName, i.isEDL, c.IssueType " + + "FROM db_iemr.m_item i " + + "JOIN db_iemr.m_itemfacilitymapping m ON m.ItemID = i.ItemID AND m.FacilityID = :facilityID AND m.Deleted = false " + + "LEFT JOIN db_iemr.m_uom u ON u.UOMID = i.UOMID " + + "LEFT JOIN db_iemr.m_itemform f ON f.ItemFormID = i.ItemFormID " + + "LEFT JOIN db_iemr.m_routeofadmin r ON r.RouteID = i.RouteID " + + "LEFT JOIN db_iemr.m_itemcategory c ON c.ItemCategoryID = i.ItemCategoryID " + + "WHERE i.ItemID = :drugID AND i.Deleted = false") + .setParameter("facilityID", facilityID) + .setParameter("drugID", drugID) + .getResultList(); + if (rows.isEmpty()) { + throw new IllegalArgumentException("drugID " + drugID + " is not available in this camp's store"); + } + return (Object[]) rows.get(0); + } + + // Usable batches (VanSerialNo, QuantityInHand), locked for this transaction, in the category's issue order. + @SuppressWarnings("unchecked") + private List lockBatches(Integer facilityID, Integer drugID, String issueType, int durationDays) { + String order; + if ("Last In First Out".equalsIgnoreCase(issueType)) order = "CreatedDate DESC, ItemStockEntryID DESC"; + else if ("First In First Out".equalsIgnoreCase(issueType)) order = "CreatedDate ASC, ItemStockEntryID ASC"; + else order = "ExpiryDate ASC, ItemStockEntryID ASC"; // First Expiry First Out (default) + Query q = em.createNativeQuery("SELECT VanSerialNo, QuantityInHand FROM db_iemr.t_itemstockentry " + + "WHERE FacilityID = :facilityID AND ItemID = :drugID AND Deleted = false AND QuantityInHand > 0 " + + "AND ExpiryDate > DATE_ADD(CURDATE(), INTERVAL :days DAY) ORDER BY " + order + " FOR UPDATE") + .setParameter("facilityID", facilityID) + .setParameter("drugID", drugID) + .setParameter("days", durationDays); + return (List) q.getResultList(); + } + + private int availableQuantity(Integer facilityID, Integer drugID, int durationDays) { + Object v = em.createNativeQuery("SELECT COALESCE(SUM(QuantityInHand), 0) FROM db_iemr.t_itemstockentry " + + "WHERE FacilityID = :facilityID AND ItemID = :drugID AND Deleted = false AND QuantityInHand > 0 " + + "AND ExpiryDate > DATE_ADD(CURDATE(), INTERVAL :days DAY)") + .setParameter("facilityID", facilityID) + .setParameter("drugID", drugID) + .setParameter("days", durationDays) + .getSingleResult(); + return ((Number) v).intValue(); + } + + private Integer getStorePsm(Integer facilityID) { + Object v = em.createNativeQuery("SELECT ProviderServiceMapID FROM db_iemr.m_facility WHERE FacilityID = :facilityID") + .setParameter("facilityID", facilityID) + .getSingleResult(); + return v != null ? ((Number) v).intValue() : null; + } + + // Longest treatment among the lines for this drug, so the batch lasts until the course ends. + private int maxDurationDays(List> drugs, Integer drugID) { + int max = 0; + for (Map d : drugs) { + if (!drugID.equals(toInt(d.get("drugID")))) continue; + Integer n = toInt(d.get("duration")); + if (n == null || n <= 0) continue; + String unit = Optional.ofNullable(toStr(d.get("durationUnit"))).orElse("Day").toLowerCase(); + int days = unit.startsWith("week") ? n * 7 : unit.startsWith("month") ? n * 30 : unit.startsWith("year") ? n * 365 : n; + max = Math.max(max, days); + } + return max; + } + + private long lastInsertId() { + return ((Number) em.createNativeQuery("SELECT LAST_INSERT_ID()").getSingleResult()).longValue(); + } + + // VanSerialNo = own ID, as every MMU/Inventory save does, so van-to-server sync can identify the row. + private void stampVanSerialNo(String table, String idColumn, long id) { + em.createNativeQuery("UPDATE db_iemr." + table + " SET VanSerialNo = " + idColumn + " WHERE " + idColumn + " = :id") + .setParameter("id", id) + .executeUpdate(); + } + + @SuppressWarnings("unchecked") + private List> toList(Object v) { + if (v instanceof List) return (List>) v; + return Collections.emptyList(); + } + + private Integer toInt(Object v) { + if (v == null) return null; + if (v instanceof Number) return ((Number) v).intValue(); + String s = v.toString().trim(); + if (s.isEmpty()) return null; + try { + return (int) Double.parseDouble(s); + } catch (NumberFormatException e) { + return null; + } + } + + private String toStr(Object v) { + return v != null ? v.toString() : null; + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index c95364ee..dc8ee545 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -30,6 +30,7 @@ import com.iemr.flw.repo.iemr.StopTBGeneralOpdRepo; import com.iemr.flw.repo.iemr.TBScreeningRepo; import com.iemr.flw.service.CampConfigService; +import com.iemr.flw.service.StopTBPrescriptionService; import com.iemr.flw.service.StopTBService; import com.iemr.flw.service.TBStopVisitService; import org.slf4j.Logger; @@ -51,6 +52,9 @@ public class StopTBServiceImpl implements StopTBService { @Autowired private CampConfigService campConfigService; + @Autowired + private StopTBPrescriptionService stopTBPrescriptionService; + @Autowired private BenFlowStatusRepo benFlowStatusRepo; @@ -367,15 +371,28 @@ private Map screeningToMap(TBScreening s) { // ── Nurse: General OPD ──────────────────────────────────────────────────── + // Whole list is one transaction: any failure (incl. insufficient stock) rolls back every record. @Override - @Transactional + @Transactional(rollbackFor = Exception.class) public List> saveGeneralOpd(List> dataList) throws Exception { List> results = new ArrayList<>(); - Integer vanID = campConfigService.getVanID(); Integer parkingPlaceID = campConfigService.getParkingPlaceID(); for (Map data : dataList) { Long beneficiaryRegID = getLong(data, "beneficiaryRegID"); if (beneficiaryRegID == null) throw new Exception("beneficiaryRegID is required"); + Integer vanID = campConfigService.resolveVanID(getInt(data, "vanID")); + + // New payload (chiefComplaints[] / prescription.drugs[]) goes to the standard tables with dispensing; + // the old payload keeps the old behaviour. + boolean standardPayload = data.get("chiefComplaints") instanceof List || data.get("prescription") instanceof Map; + String submissionId = getString(data, "submissionId"); + if (standardPayload) { + Map previous = stopTBPrescriptionService.findPreviousSubmission(beneficiaryRegID, submissionId); + if (previous != null) { + results.add(previous); + continue; + } + } Integer providerServiceMapID = getInt(data, "providerServiceMapID"); String createdBy = getString(data, "createdBy"); @@ -390,8 +407,10 @@ public List> saveGeneralOpd(List> dataLi opd.setVisitCode(visit.getVisitCode()); opd.setBenVisitID(visit.getBenVisitId()); opd.setProviderServiceMapID(providerServiceMapID); - opd.setChiefComplaint(toJsonString(data.get("chiefComplaint"))); - opd.setMedication(getString(data, "medication")); + opd.setChiefComplaint(toJsonString(data.get(standardPayload && data.get("chiefComplaint") == null + ? "chiefComplaints" : "chiefComplaint"))); + opd.setMedication(standardPayload && data.get("medication") == null + ? drugNames(data.get("prescription")) : getString(data, "medication")); opd.setDosage(getString(data, "dosage")); opd.setFrequency(getString(data, "frequency")); opd.setDuration(getString(data, "duration")); @@ -403,19 +422,46 @@ public List> saveGeneralOpd(List> dataLi opd.setProcessed("N"); generalOpdRepo.save(opd); - if (isNew) { - generalOpdRepo.updateVanSerialNo(opd.getId()); - dualWriteOpdToStandardTables(opd, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); - } + if (isNew) generalOpdRepo.updateVanSerialNo(opd.getId()); Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); result.put("visitCode", visit.getVisitCode()); + if (standardPayload) { + stopTBPrescriptionService.saveChiefComplaints(toMapList(data.get("chiefComplaints")), beneficiaryRegID, + visit, createdBy, vanID, parkingPlaceID); + if (data.get("prescription") instanceof Map) { + result.putAll(stopTBPrescriptionService.prescribeAndDispense( + (Map) data.get("prescription"), beneficiaryRegID, visit, submissionId, + createdBy, vanID, parkingPlaceID)); + } + } else if (isNew) { + dualWriteOpdToStandardTables(opd, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + } results.add(result); } return results; } + @SuppressWarnings("unchecked") + private List> toMapList(Object v) { + return v instanceof List ? (List>) v : null; + } + + // Readable drug list kept in the archive row so getAll / Nikshay export still show the medication. + @SuppressWarnings("unchecked") + private String drugNames(Object prescription) { + if (!(prescription instanceof Map)) return null; + List> drugs = toMapList(((Map) prescription).get("drugs")); + if (drugs == null || drugs.isEmpty()) return null; + StringJoiner names = new StringJoiner(", "); + for (Map d : drugs) { + Object name = d.get("drugName") != null ? d.get("drugName") : "drugID " + d.get("drugID"); + names.add(name + " x" + d.get("qtyPrescribed")); + } + return names.toString(); + } + @Override public Map getAllGeneralOpd(Integer providerServiceMapID, Integer villageID) throws Exception { List list = (villageID != null) From f4039529bcf74e4f840d8a035c6d1f4f1569411f Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 09:27:59 +0530 Subject: [PATCH 03/33] fix(stoptb): close dispensing gaps found in review - Always use stoptb.van.id; a vanID sent by the app is ignored (logged). - Reduced batch gets Processed='N' so the new quantity uploads to central; removed chief complaints are re-sent the same way. - Batches with no expiry date are usable, as in v_drugforprescription. - Drug must be mapped to the store on its own service line, same join as the drug list, so the save accepts exactly what the list shows. - Patient name, age and gender filled on t_patientissue for the Inventory dispense screen. Co-Authored-By: Claude Opus 5.5 --- .../iemr/flw/service/CampConfigService.java | 11 +-- .../service/StopTBPrescriptionService.java | 73 ++++++++++++++++--- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/CampConfigService.java b/src/main/java/com/iemr/flw/service/CampConfigService.java index 11cf2340..325b0dab 100644 --- a/src/main/java/com/iemr/flw/service/CampConfigService.java +++ b/src/main/java/com/iemr/flw/service/CampConfigService.java @@ -57,16 +57,13 @@ public Integer getVanID() { } /** - * The configured van is the source of truth. A vanID sent by the app is only checked against it: - * missing -> configured van; same -> OK; different -> rejected, so data and stock never land on - * another camp's van/store. + * The configured van (stoptb.van.id) is always used. A vanID sent by the app is ignored, so data and + * stock never land on another camp's van/store; a different value is only logged. */ public Integer resolveVanID(Integer requestedVanID) { Integer configured = getVanID(); - if (requestedVanID != null && requestedVanID > 0 && configured != null - && !requestedVanID.equals(configured)) { - throw new IllegalArgumentException("vanID " + requestedVanID - + " does not match this camp's van " + configured); + if (requestedVanID != null && requestedVanID > 0 && !requestedVanID.equals(configured)) { + logger.warn("Ignoring vanID " + requestedVanID + " from request; using stoptb.van.id " + configured); } return configured; } diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java index 061b1ba7..349a14e1 100644 --- a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -1,14 +1,22 @@ package com.iemr.flw.service; +import com.iemr.flw.domain.identity.RMNCHMBeneficiarydetail; +import com.iemr.flw.domain.identity.RMNCHMBeneficiarymapping; import com.iemr.flw.domain.iemr.BenVisitDetail; +import com.iemr.flw.repo.identity.BeneficiaryRepo; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.Query; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.Period; import java.util.*; /** @@ -37,6 +45,11 @@ public class StopTBPrescriptionService { @Autowired private CampConfigService campConfigService; + @Autowired + private BeneficiaryRepo beneficiaryRepo; + + private final Logger logger = LoggerFactory.getLogger(StopTBPrescriptionService.class); + public static class InsufficientStockException extends RuntimeException { public InsufficientStockException(String message) { super(message); @@ -93,7 +106,8 @@ public Map findPreviousSubmission(Long beneficiaryRegID, String public void saveChiefComplaints(List> complaints, Long beneficiaryRegID, BenVisitDetail visit, String createdBy, Integer vanID, Integer parkingPlaceID) { if (complaints == null) return; - em.createNativeQuery("UPDATE db_iemr.t_benchiefcomplaint SET Deleted = true, ModifiedBy = :user " + // Processed='N' so the removal is uploaded to central again (central updates rows it already has). + em.createNativeQuery("UPDATE db_iemr.t_benchiefcomplaint SET Deleted = true, Processed = 'N', ModifiedBy = :user " + "WHERE BeneficiaryRegID = :ben AND VisitCode = :visitCode AND Deleted = false") .setParameter("user", createdBy) .setParameter("ben", beneficiaryRegID) @@ -218,11 +232,16 @@ public Map prescribeAndDispense(Map prescription } // 3. Patient issue header (same shape as Inventory-API issuePatientDrugs, IssueType 'System'). + Object[] patient = getPatientDetails(beneficiaryRegID); em.createNativeQuery("INSERT INTO db_iemr.t_patientissue (BeneficiaryRegID, BenVisitID, VisitCode, FacilityID, " - + "PrescriptionID, Reference, IssueType, IssuedBy, ProviderServiceMapID, Deleted, Processed, CreatedBy, " - + "SyncFacilityID, VanID, ParkingPlaceID) VALUES (:ben, :visitID, :visitCode, :facilityID, " - + ":prescriptionID, :ref, 'System', :user, :psm, false, 'N', :user, :facilityID, :van, :pp)") + + "PatientName, Age, Gender, PrescriptionID, Reference, IssueType, IssuedBy, ProviderServiceMapID, " + + "Deleted, Processed, CreatedBy, SyncFacilityID, VanID, ParkingPlaceID) VALUES (:ben, :visitID, " + + ":visitCode, :facilityID, :name, :age, :gender, :prescriptionID, :ref, 'System', :user, :psm, false, " + + "'N', :user, :facilityID, :van, :pp)") .setParameter("ben", beneficiaryRegID) + .setParameter("name", patient[0]) + .setParameter("age", patient[1]) + .setParameter("gender", patient[2]) .setParameter("visitID", visit.getBenVisitId()) .setParameter("visitCode", visit.getVisitCode()) .setParameter("facilityID", facilityID) @@ -258,9 +277,10 @@ public Map prescribeAndDispense(Map prescription .setParameter("pp", parkingPlaceID) .executeUpdate(); stampVanSerialNo("t_itemstockexit", "ItemStockExitID", lastInsertId()); - // Same update as Inventory-API ItemStockEntryRepo.updateStock - em.createNativeQuery("UPDATE db_iemr.t_itemstockentry SET QuantityInHand = QuantityInHand - :qty " - + "WHERE VanSerialNo = :batch AND FacilityID = :facilityID") + // Same update as Inventory-API ItemStockEntryRepo.updateStock, plus Processed='N' so the new + // quantity of an already-synced batch is uploaded again; central updates the existing row. + em.createNativeQuery("UPDATE db_iemr.t_itemstockentry SET QuantityInHand = QuantityInHand - :qty, " + + "Processed = 'N' WHERE VanSerialNo = :batch AND FacilityID = :facilityID") .setParameter("qty", take) .setParameter("batch", batchSerialNo) .setParameter("facilityID", facilityID) @@ -280,12 +300,38 @@ public Map prescribeAndDispense(Map prescription return result; } - // ItemID, ItemName, strength+UOM, form, route, isEDL, IssueType — only if the drug is mapped to the store. + // Name, age (years), gender for the issue row shown on the Inventory dispense screen. Missing details + // never block dispensing, same as Inventory-API. + private Object[] getPatientDetails(Long beneficiaryRegID) { + try { + RMNCHMBeneficiarymapping mapping = beneficiaryRepo.getById(BigInteger.valueOf(beneficiaryRegID)); + RMNCHMBeneficiarydetail detail = (mapping != null && mapping.getBenDetailsId() != null) + ? beneficiaryRepo.getDetailsById(mapping.getBenDetailsId()) + : null; + if (detail == null) return new Object[3]; + StringJoiner name = new StringJoiner(" "); + for (String part : new String[] { detail.getFirstName(), detail.getMiddleName(), detail.getLastName() }) { + if (part != null && !part.isBlank()) name.add(part.trim()); + } + Integer age = detail.getDob() != null + ? Period.between(detail.getDob().toLocalDateTime().toLocalDate(), LocalDate.now()).getYears() + : null; + String fullName = name.length() > 150 ? name.toString().substring(0, 150) : name.toString(); + return new Object[] { fullName.isEmpty() ? null : fullName, age, detail.getGender() }; + } catch (Exception e) { + logger.warn("Cannot read patient details for benRegID " + beneficiaryRegID + ": " + e.getMessage()); + return new Object[3]; + } + } + + // ItemID, ItemName, strength+UOM, form, route, isEDL, IssueType — only if the drug is mapped to the store + // on its own service line (same join as v_drugforprescription, so the save accepts what the list shows). private Object[] getStoreItem(Integer facilityID, Integer drugID) { List rows = em.createNativeQuery("SELECT i.ItemID, i.ItemName, CONCAT_WS(' ', i.Strength, u.UOMName), " + "f.ItemFormName, r.RouteName, i.isEDL, c.IssueType " + "FROM db_iemr.m_item i " - + "JOIN db_iemr.m_itemfacilitymapping m ON m.ItemID = i.ItemID AND m.FacilityID = :facilityID AND m.Deleted = false " + + "JOIN db_iemr.m_itemfacilitymapping m ON m.ItemID = i.ItemID AND m.FacilityID = :facilityID " + + "AND m.ProviderServiceMapID = i.ProviderServiceMapID AND m.Deleted = false " + "LEFT JOIN db_iemr.m_uom u ON u.UOMID = i.UOMID " + "LEFT JOIN db_iemr.m_itemform f ON f.ItemFormID = i.ItemFormID " + "LEFT JOIN db_iemr.m_routeofadmin r ON r.RouteID = i.RouteID " @@ -300,16 +346,19 @@ private Object[] getStoreItem(Integer facilityID, Integer drugID) { return (Object[]) rows.get(0); } + // A batch is usable if it has no expiry date (counted by v_drugforprescription too) or lasts the course. + private static final String USABLE_BATCH = "AND (ExpiryDate IS NULL OR ExpiryDate > DATE_ADD(CURDATE(), INTERVAL :days DAY)) "; + // Usable batches (VanSerialNo, QuantityInHand), locked for this transaction, in the category's issue order. @SuppressWarnings("unchecked") private List lockBatches(Integer facilityID, Integer drugID, String issueType, int durationDays) { String order; if ("Last In First Out".equalsIgnoreCase(issueType)) order = "CreatedDate DESC, ItemStockEntryID DESC"; else if ("First In First Out".equalsIgnoreCase(issueType)) order = "CreatedDate ASC, ItemStockEntryID ASC"; - else order = "ExpiryDate ASC, ItemStockEntryID ASC"; // First Expiry First Out (default) + else order = "ExpiryDate IS NULL, ExpiryDate ASC, ItemStockEntryID ASC"; // First Expiry First Out (default) Query q = em.createNativeQuery("SELECT VanSerialNo, QuantityInHand FROM db_iemr.t_itemstockentry " + "WHERE FacilityID = :facilityID AND ItemID = :drugID AND Deleted = false AND QuantityInHand > 0 " - + "AND ExpiryDate > DATE_ADD(CURDATE(), INTERVAL :days DAY) ORDER BY " + order + " FOR UPDATE") + + USABLE_BATCH + "ORDER BY " + order + " FOR UPDATE") .setParameter("facilityID", facilityID) .setParameter("drugID", drugID) .setParameter("days", durationDays); @@ -319,7 +368,7 @@ private List lockBatches(Integer facilityID, Integer drugID, String is private int availableQuantity(Integer facilityID, Integer drugID, int durationDays) { Object v = em.createNativeQuery("SELECT COALESCE(SUM(QuantityInHand), 0) FROM db_iemr.t_itemstockentry " + "WHERE FacilityID = :facilityID AND ItemID = :drugID AND Deleted = false AND QuantityInHand > 0 " - + "AND ExpiryDate > DATE_ADD(CURDATE(), INTERVAL :days DAY)") + + USABLE_BATCH) .setParameter("facilityID", facilityID) .setParameter("drugID", drugID) .setParameter("days", durationDays) From 5ad0b40119aa1da7759f5b0f9fb35641d9c2b10b Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 13:51:08 +0530 Subject: [PATCH 04/33] feat(stoptb): General OPD on AMRIT standard tables only - New payload (chiefComplaints[] / prescription.drugs[]) no longer writes tb_stoptb_general_opd; complaints, prescription and dispensing live only in t_benchiefcomplaint, t_prescription, t_prescribeddrug, t_patientissue, t_itemstockexit. - generalOpd/getAll reads the standard tables first and falls back to tb_stoptb_general_opd only for older visits that exist only there. Response fields and formats are unchanged for the app. - Old app payload keeps its current behaviour. Co-Authored-By: Claude Opus 5.5 --- .../service/StopTBPrescriptionService.java | 103 ++++++++++++++++++ .../flw/service/impl/StopTBServiceImpl.java | 81 +++++++------- 2 files changed, 145 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java index 349a14e1..9059ca9d 100644 --- a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -300,6 +300,109 @@ public Map prescribeAndDispense(Map prescription return result; } + /** + * OPD records for getAll from the standard tables, one per visit that was saved with the new payload + * (complaint with ChiefComplaintID or drug with DrugID). Older visits have only the free-text copy in + * tb_stoptb_general_opd and are added by the caller. Field names and formats match that table's response: + * chiefComplaint = JSON list of names, medication/dosage/frequency/duration = comma separated. + */ + @SuppressWarnings("unchecked") + public List> getStandardOpdRecords(Integer providerServiceMapID, Integer villageID) { + String villageFilter = villageID != null + ? "AND v.BeneficiaryRegID IN (SELECT f.beneficiary_reg_id FROM db_iemr.i_ben_flow_outreach f " + + "WHERE f.providerServiceMapID = :psm AND f.villageID = :villageID) " + : ""; + Query visitQuery = em.createNativeQuery("SELECT v.BenVisitID, v.BeneficiaryRegID, v.VisitCode, v.CreatedBy, " + + "v.CreatedDate, v.LastModDate FROM db_iemr.t_benvisitdetail v " + + "WHERE v.ProviderServiceMapID = :psm AND v.Deleted = false " + + "AND (EXISTS (SELECT 1 FROM db_iemr.t_benchiefcomplaint c WHERE c.BeneficiaryRegID = v.BeneficiaryRegID " + + " AND c.VisitCode = v.VisitCode AND c.ChiefComplaintID IS NOT NULL AND c.Deleted = false) " + + " OR EXISTS (SELECT 1 FROM db_iemr.t_prescribeddrug d WHERE d.BeneficiaryRegID = v.BeneficiaryRegID " + + " AND d.VisitCode = v.VisitCode AND d.DrugID IS NOT NULL AND d.Deleted = false)) " + + villageFilter + "ORDER BY v.CreatedDate DESC") + .setParameter("psm", providerServiceMapID); + if (villageID != null) visitQuery.setParameter("villageID", villageID); + List visits = visitQuery.getResultList(); + + Map> byVisit = new LinkedHashMap<>(); + for (Object[] v : visits) { + Map m = new LinkedHashMap<>(); + m.put("id", ((Number) v[0]).longValue()); + m.put("beneficiaryRegID", ((Number) v[1]).longValue()); + m.put("visitCode", ((Number) v[2]).longValue()); + m.put("providerServiceMapID", providerServiceMapID); + m.put("chiefComplaint", new ArrayList()); + m.put("medication", new StringJoiner(", ")); + m.put("dosage", new StringJoiner(", ")); + m.put("frequency", new StringJoiner(", ")); + m.put("duration", new StringJoiner(", ")); + m.put("notes", null); + m.put("createdBy", v[3]); + m.put("createdDate", v[4]); + m.put("updateDate", v[5]); + m.put("updatedBy", null); + byVisit.put(((Number) v[2]).longValue(), m); + } + if (byVisit.isEmpty()) return new ArrayList<>(); + + List visitCodes = new ArrayList<>(byVisit.keySet()); + for (int i = 0; i < visitCodes.size(); i += 500) { + List chunk = visitCodes.subList(i, Math.min(i + 500, visitCodes.size())); + + for (Object o : em.createNativeQuery("SELECT VisitCode, ChiefComplaint, LastModDate FROM db_iemr.t_benchiefcomplaint " + + "WHERE VisitCode IN (:codes) AND ChiefComplaintID IS NOT NULL AND Deleted = false ORDER BY ID") + .setParameter("codes", chunk).getResultList()) { + Object[] r = (Object[]) o; + Map m = byVisit.get(((Number) r[0]).longValue()); + if (r[1] != null) ((List) m.get("chiefComplaint")).add(r[1].toString()); + latest(m, r[2]); + } + + for (Object o : em.createNativeQuery("SELECT VisitCode, GenericDrugName, Dose, Frequency, Duration, DuartionUnit, " + + "LastModDate FROM db_iemr.t_prescribeddrug WHERE VisitCode IN (:codes) AND DrugID IS NOT NULL " + + "AND Deleted = false ORDER BY PrescribedDrugID") + .setParameter("codes", chunk).getResultList()) { + Object[] r = (Object[]) o; + Map m = byVisit.get(((Number) r[0]).longValue()); + ((StringJoiner) m.get("medication")).add(r[1] != null ? r[1].toString() : ""); + ((StringJoiner) m.get("dosage")).add(r[2] != null ? r[2].toString() : ""); + ((StringJoiner) m.get("frequency")).add(r[3] != null ? r[3].toString() : ""); + ((StringJoiner) m.get("duration")).add(r[4] == null ? "" : r[5] == null ? r[4].toString() : r[4] + " " + r[5]); + latest(m, r[6]); + } + + // Latest prescription instruction per visit is the visit's note. + for (Object o : em.createNativeQuery("SELECT VisitCode, Instruction, LastModDate FROM db_iemr.t_prescription " + + "WHERE VisitCode IN (:codes) AND Deleted = false ORDER BY PrescriptionID") + .setParameter("codes", chunk).getResultList()) { + Object[] r = (Object[]) o; + Map m = byVisit.get(((Number) r[0]).longValue()); + if (r[1] != null && !r[1].toString().isBlank()) m.put("notes", r[1].toString()); + latest(m, r[2]); + } + } + + com.google.gson.Gson gson = new com.google.gson.Gson(); + List> records = new ArrayList<>(); + for (Map m : byVisit.values()) { + List complaints = (List) m.get("chiefComplaint"); + m.put("chiefComplaint", complaints.isEmpty() ? null : gson.toJson(complaints)); + for (String key : new String[] { "medication", "dosage", "frequency", "duration" }) { + String joined = m.get(key).toString(); + m.put(key, joined.replace(",", "").isBlank() ? null : joined); + } + records.add(m); + } + return records; + } + + // updateDate = latest change on the visit, so the app picks up a later save on the same day. + private void latest(Map m, Object modDate) { + if (modDate instanceof Date && (m.get("updateDate") == null || ((Date) modDate).after((Date) m.get("updateDate")))) { + m.put("updateDate", modDate); + } + } + // Name, age (years), gender for the issue row shown on the Inventory dispense screen. Missing details // never block dispensing, same as Inventory-API. private Object[] getPatientDetails(Long beneficiaryRegID) { 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 dc8ee545..8fdddc31 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -382,20 +382,35 @@ public List> saveGeneralOpd(List> dataLi if (beneficiaryRegID == null) throw new Exception("beneficiaryRegID is required"); Integer vanID = campConfigService.resolveVanID(getInt(data, "vanID")); - // New payload (chiefComplaints[] / prescription.drugs[]) goes to the standard tables with dispensing; - // the old payload keeps the old behaviour. - boolean standardPayload = data.get("chiefComplaints") instanceof List || data.get("prescription") instanceof Map; - String submissionId = getString(data, "submissionId"); - if (standardPayload) { + Integer providerServiceMapID = getInt(data, "providerServiceMapID"); + String createdBy = getString(data, "createdBy"); + + // New payload (chiefComplaints[] / prescription.drugs[]): AMRIT standard tables only, with dispensing. + // tb_stoptb_general_opd is not written; getAll and the Nikshay export read the standard tables. + if (data.get("chiefComplaints") instanceof List || data.get("prescription") instanceof Map) { + String submissionId = getString(data, "submissionId"); Map previous = stopTBPrescriptionService.findPreviousSubmission(beneficiaryRegID, submissionId); if (previous != null) { results.add(previous); continue; } + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, + createdBy, vanID, parkingPlaceID); + Map result = new LinkedHashMap<>(); + result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", visit.getVisitCode()); + stopTBPrescriptionService.saveChiefComplaints(toMapList(data.get("chiefComplaints")), beneficiaryRegID, + visit, createdBy, vanID, parkingPlaceID); + if (data.get("prescription") instanceof Map) { + result.putAll(stopTBPrescriptionService.prescribeAndDispense( + (Map) data.get("prescription"), beneficiaryRegID, visit, submissionId, + createdBy, vanID, parkingPlaceID)); + } + results.add(result); + continue; } - Integer providerServiceMapID = getInt(data, "providerServiceMapID"); - String createdBy = getString(data, "createdBy"); + // Old app payload (free-text chiefComplaint / medication): unchanged behaviour. BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, createdBy, vanID, parkingPlaceID); @@ -407,10 +422,8 @@ public List> saveGeneralOpd(List> dataLi opd.setVisitCode(visit.getVisitCode()); opd.setBenVisitID(visit.getBenVisitId()); opd.setProviderServiceMapID(providerServiceMapID); - opd.setChiefComplaint(toJsonString(data.get(standardPayload && data.get("chiefComplaint") == null - ? "chiefComplaints" : "chiefComplaint"))); - opd.setMedication(standardPayload && data.get("medication") == null - ? drugNames(data.get("prescription")) : getString(data, "medication")); + opd.setChiefComplaint(toJsonString(data.get("chiefComplaint"))); + opd.setMedication(getString(data, "medication")); opd.setDosage(getString(data, "dosage")); opd.setFrequency(getString(data, "frequency")); opd.setDuration(getString(data, "duration")); @@ -422,22 +435,14 @@ public List> saveGeneralOpd(List> dataLi opd.setProcessed("N"); generalOpdRepo.save(opd); - if (isNew) generalOpdRepo.updateVanSerialNo(opd.getId()); + if (isNew) { + generalOpdRepo.updateVanSerialNo(opd.getId()); + dualWriteOpdToStandardTables(opd, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + } Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); result.put("visitCode", visit.getVisitCode()); - if (standardPayload) { - stopTBPrescriptionService.saveChiefComplaints(toMapList(data.get("chiefComplaints")), beneficiaryRegID, - visit, createdBy, vanID, parkingPlaceID); - if (data.get("prescription") instanceof Map) { - result.putAll(stopTBPrescriptionService.prescribeAndDispense( - (Map) data.get("prescription"), beneficiaryRegID, visit, submissionId, - createdBy, vanID, parkingPlaceID)); - } - } else if (isNew) { - dualWriteOpdToStandardTables(opd, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); - } results.add(result); } return results; @@ -448,27 +453,25 @@ private List> toMapList(Object v) { return v instanceof List ? (List>) v : null; } - // Readable drug list kept in the archive row so getAll / Nikshay export still show the medication. - @SuppressWarnings("unchecked") - private String drugNames(Object prescription) { - if (!(prescription instanceof Map)) return null; - List> drugs = toMapList(((Map) prescription).get("drugs")); - if (drugs == null || drugs.isEmpty()) return null; - StringJoiner names = new StringJoiner(", "); - for (Map d : drugs) { - Object name = d.get("drugName") != null ? d.get("drugName") : "drugID " + d.get("drugID"); - names.add(name + " x" + d.get("qtyPrescribed")); - } - return names.toString(); - } - + // Standard tables first (visits saved with the new payload); for older visits, which exist only in + // tb_stoptb_general_opd, fall back to that table. Same response fields either way. @Override public Map getAllGeneralOpd(Integer providerServiceMapID, Integer villageID) throws Exception { + List> items = stopTBPrescriptionService.getStandardOpdRecords(providerServiceMapID, villageID); + Set standardVisitCodes = new HashSet<>(); + for (Map item : items) standardVisitCodes.add((Long) item.get("visitCode")); + List list = (villageID != null) ? generalOpdRepo.findAllByProviderServiceMapIDAndVillageID(providerServiceMapID, villageID) : generalOpdRepo.findAllByProviderServiceMapID(providerServiceMapID); - List> items = new ArrayList<>(); - for (StopTBGeneralOpd o : list) items.add(opdToMap(o)); + for (StopTBGeneralOpd o : list) { + if (o.getVisitCode() == null || !standardVisitCodes.contains(o.getVisitCode())) items.add(opdToMap(o)); + } + items.sort((a, b) -> { + Date da = (Date) a.get("createdDate"), db = (Date) b.get("createdDate"); + if (da == null || db == null) return da == null ? (db == null ? 0 : 1) : -1; + return db.compareTo(da); + }); Map result = new HashMap<>(); result.put("data", items); result.put("count", items.size()); From 727cfdd4d416fe3107920f88b48b2d0ac1d6e280 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 14:17:31 +0530 Subject: [PATCH 05/33] feat(stoptb): calculate tablet/capsule quantity when the app sends none qtyPrescribed from the app is used as is. When it is missing, for tablets and capsules (MMU's prescription screen asks quantity only for other forms) it is doses per day x days from frequency and duration; Single/Stat dose = 1, Once in a Week = 1 per 7 days. SOS or other forms without a quantity are rejected. The calculated value is saved in t_prescribeddrug.QtyPrescribed and dispensed. Co-Authored-By: Claude Opus 5.5 --- .../service/StopTBPrescriptionService.java | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java index 9059ca9d..ce19a8b4 100644 --- a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -159,10 +159,18 @@ public Map prescribeAndDispense(Map prescription Map items = new HashMap<>(); for (Map d : drugs) { Integer drugID = toInt(d.get("drugID")); - Integer qty = toInt(d.get("qtyPrescribed")); if (drugID == null) throw new IllegalArgumentException("drugID is required for every drug"); - if (qty == null || qty <= 0) throw new IllegalArgumentException("qtyPrescribed must be more than 0 for drugID " + drugID); if (!items.containsKey(drugID)) items.put(drugID, getStoreItem(facilityID, drugID)); + Integer qty = toInt(d.get("qtyPrescribed")); + if (qty == null || qty <= 0) { + // Same as MMU's prescription screen: no quantity is entered for tablets/capsules, so work it out. + qty = calculateQuantity(d, items.get(drugID)); + if (qty == null) { + throw new IllegalArgumentException("qtyPrescribed is required for drugID " + drugID + + " (quantity can be calculated only for tablets/capsules with frequency and duration)"); + } + d.put("qtyPrescribed", qty); // saved in t_prescribeddrug.QtyPrescribed + } requested.merge(drugID, qty, Integer::sum); } List shortages = new ArrayList<>(); @@ -427,11 +435,38 @@ private Object[] getPatientDetails(Long beneficiaryRegID) { } } - // ItemID, ItemName, strength+UOM, form, route, isEDL, IssueType — only if the drug is mapped to the store - // on its own service line (same join as v_drugforprescription, so the save accepts what the list shows). + /** + * Units to give when the app sends no quantity: doses per day x days, for tablets/capsules only + * (MMU's prescription screen asks quantity only for other forms). Null when it cannot be worked out + * (other forms, SOS, missing frequency or duration). + */ + private Integer calculateQuantity(Map drug, Object[] item) { + Integer formID = item[7] != null ? ((Number) item[7]).intValue() : null; + if (formID == null || (formID != 1 && formID != 2)) return null; // 1 = Tablet, 2 = Capsule + String frequency = Optional.ofNullable(toStr(drug.get("frequency"))).orElse("").toUpperCase(); + if (frequency.contains("SINGLE DOSE") || frequency.contains("STAT")) return 1; + + Integer duration = toInt(drug.get("duration")); + if (duration == null || duration <= 0) return null; + String unit = Optional.ofNullable(toStr(drug.get("durationUnit"))).orElse("Day").toLowerCase(); + int days = unit.startsWith("week") ? duration * 7 : unit.startsWith("month") ? duration * 30 + : unit.startsWith("year") ? duration * 365 : duration; + + if (frequency.contains("WEEK")) return (days + 6) / 7; // Once in a Week + int perDay; + if (frequency.contains("QID") || frequency.contains("FOUR")) perDay = 4; + else if (frequency.contains("TID") || frequency.contains("THRICE")) perDay = 3; + else if (frequency.contains("BD") || frequency.contains("TWICE")) perDay = 2; + else if (frequency.contains("OD") || frequency.contains("ONCE DAILY")) perDay = 1; + else return null; // SOS or unknown + return perDay * days; + } + + // ItemID, ItemName, strength+UOM, form, route, isEDL, IssueType, ItemFormID — only if the drug is mapped to + // the store on its own service line (same join as v_drugforprescription, so the save accepts what the list shows). private Object[] getStoreItem(Integer facilityID, Integer drugID) { List rows = em.createNativeQuery("SELECT i.ItemID, i.ItemName, CONCAT_WS(' ', i.Strength, u.UOMName), " - + "f.ItemFormName, r.RouteName, i.isEDL, c.IssueType " + + "f.ItemFormName, r.RouteName, i.isEDL, c.IssueType, i.ItemFormID " + "FROM db_iemr.m_item i " + "JOIN db_iemr.m_itemfacilitymapping m ON m.ItemID = i.ItemID AND m.FacilityID = :facilityID " + "AND m.ProviderServiceMapID = i.ProviderServiceMapID AND m.Deleted = false " From 2046861646dc81f43c6e3581f5eb25888ef990e8 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 14:49:35 +0530 Subject: [PATCH 06/33] fix(stoptb): OPD save validations from final review - chiefComplaintID is required for each chief complaint (getAll and reports identify standard-table records by it). - prescription.instruction is saved in t_prescription even when no drug is given, as MMU does for the visit instruction. - providerServiceMapID and createdBy give a clear error when missing instead of a database error. Co-Authored-By: Claude Opus 5.5 --- .../service/StopTBPrescriptionService.java | 52 +++++++++++++------ .../flw/service/impl/StopTBServiceImpl.java | 2 + 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java index ce19a8b4..5fbb41f7 100644 --- a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -116,7 +116,11 @@ public void saveChiefComplaints(List> complaints, Long benef for (Map c : complaints) { Integer complaintID = toInt(c.get("chiefComplaintID")); String complaint = toStr(c.get("chiefComplaint")); - if (complaintID == null && (complaint == null || complaint.isBlank())) continue; + // ID from the chief complaint master is required: getAll and reports identify new records by it. + if (complaintID == null) { + throw new IllegalArgumentException("chiefComplaintID is required for every chief complaint" + + (complaint != null ? " (" + complaint + ")" : "")); + } em.createNativeQuery("INSERT INTO db_iemr.t_benchiefcomplaint (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, " + "VisitCode, ChiefComplaintID, ChiefComplaint, Duration, UnitOfDuration, Description, Deleted, " + "Processed, CreatedBy, VanID, ParkingPlaceID) VALUES (:ben, :visitID, :psm, :visitCode, :cid, " @@ -147,7 +151,15 @@ public Map prescribeAndDispense(Map prescription BenVisitDetail visit, String submissionId, String createdBy, Integer vanID, Integer parkingPlaceID) { List> drugs = toList(prescription.get("drugs")); Map result = new LinkedHashMap<>(); - if (drugs.isEmpty()) return result; + if (drugs.isEmpty()) { + // Remarks without medicine are still kept, as MMU saves the visit instruction without drugs. + String instruction = toStr(prescription.get("instruction")); + if (instruction != null && !instruction.isBlank()) { + result.put("prescriptionID", insertPrescription(beneficiaryRegID, visit, instruction, createdBy, vanID, + parkingPlaceID)); + } + return result; + } Integer facilityID = campConfigService.getFacilityID(); if (facilityID == null) { @@ -191,20 +203,8 @@ public Map prescribeAndDispense(Map prescription // 2. Prescription + prescribed drugs (Stop TB service line, same as the visit). Integer storePsm = getStorePsm(facilityID); - em.createNativeQuery("INSERT INTO db_iemr.t_prescription (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, VisitCode, " - + "Instruction, Deleted, Processed, CreatedBy, VanID, ParkingPlaceID) " - + "VALUES (:ben, :visitID, :psm, :visitCode, :instruction, false, 'N', :user, :van, :pp)") - .setParameter("ben", beneficiaryRegID) - .setParameter("visitID", visit.getBenVisitId()) - .setParameter("psm", visit.getProviderServiceMapID()) - .setParameter("visitCode", visit.getVisitCode()) - .setParameter("instruction", toStr(prescription.get("instruction"))) - .setParameter("user", createdBy) - .setParameter("van", vanID) - .setParameter("pp", parkingPlaceID) - .executeUpdate(); - long prescriptionID = lastInsertId(); - stampVanSerialNo("t_prescription", "PrescriptionID", prescriptionID); + long prescriptionID = insertPrescription(beneficiaryRegID, visit, toStr(prescription.get("instruction")), + createdBy, vanID, parkingPlaceID); for (Map d : drugs) { Integer drugID = toInt(d.get("drugID")); @@ -435,6 +435,26 @@ private Object[] getPatientDetails(Long beneficiaryRegID) { } } + // t_prescription row for the visit (Stop TB service line, same as the visit). + private long insertPrescription(Long beneficiaryRegID, BenVisitDetail visit, String instruction, String createdBy, + Integer vanID, Integer parkingPlaceID) { + em.createNativeQuery("INSERT INTO db_iemr.t_prescription (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, VisitCode, " + + "Instruction, Deleted, Processed, CreatedBy, VanID, ParkingPlaceID) " + + "VALUES (:ben, :visitID, :psm, :visitCode, :instruction, false, 'N', :user, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("psm", visit.getProviderServiceMapID()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("instruction", instruction) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + long prescriptionID = lastInsertId(); + stampVanSerialNo("t_prescription", "PrescriptionID", prescriptionID); + return prescriptionID; + } + /** * Units to give when the app sends no quantity: doses per day x days, for tablets/capsules only * (MMU's prescription screen asks quantity only for other forms). Null when it cannot be worked out 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 8fdddc31..5d451ccf 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -388,6 +388,8 @@ public List> saveGeneralOpd(List> dataLi // New payload (chiefComplaints[] / prescription.drugs[]): AMRIT standard tables only, with dispensing. // tb_stoptb_general_opd is not written; getAll and the Nikshay export read the standard tables. if (data.get("chiefComplaints") instanceof List || data.get("prescription") instanceof Map) { + if (providerServiceMapID == null) throw new Exception("providerServiceMapID is required"); + if (createdBy == null || createdBy.isBlank()) throw new Exception("createdBy is required"); String submissionId = getString(data, "submissionId"); Map previous = stopTBPrescriptionService.findPreviousSubmission(beneficiaryRegID, submissionId); if (previous != null) { From 3bb529f9c5908b480b6e0073a370c0968f21390d Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 15:24:44 +0530 Subject: [PATCH 07/33] feat(stoptb): default quantity 1 for non-tablet medicines when the app sends none Co-Authored-By: Claude Opus 5.5 --- .../iemr/flw/service/StopTBPrescriptionService.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java index 5fbb41f7..8b13682e 100644 --- a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -175,11 +175,11 @@ public Map prescribeAndDispense(Map prescription if (!items.containsKey(drugID)) items.put(drugID, getStoreItem(facilityID, drugID)); Integer qty = toInt(d.get("qtyPrescribed")); if (qty == null || qty <= 0) { - // Same as MMU's prescription screen: no quantity is entered for tablets/capsules, so work it out. + // No quantity from the app: tablets/capsules = doses per day x days, other forms = 1. qty = calculateQuantity(d, items.get(drugID)); if (qty == null) { throw new IllegalArgumentException("qtyPrescribed is required for drugID " + drugID - + " (quantity can be calculated only for tablets/capsules with frequency and duration)"); + + " (tablet/capsule quantity needs frequency and duration; not possible for SOS)"); } d.put("qtyPrescribed", qty); // saved in t_prescribeddrug.QtyPrescribed } @@ -456,13 +456,13 @@ private long insertPrescription(Long beneficiaryRegID, BenVisitDetail visit, Str } /** - * Units to give when the app sends no quantity: doses per day x days, for tablets/capsules only - * (MMU's prescription screen asks quantity only for other forms). Null when it cannot be worked out - * (other forms, SOS, missing frequency or duration). + * Units to give when the app sends no quantity. Tablets/capsules: doses per day x days. Other forms + * (syrup, cream, injection, drops...): 1 bottle/tube/vial. Null when a tablet/capsule quantity cannot be + * worked out (SOS, missing frequency or duration). */ private Integer calculateQuantity(Map drug, Object[] item) { Integer formID = item[7] != null ? ((Number) item[7]).intValue() : null; - if (formID == null || (formID != 1 && formID != 2)) return null; // 1 = Tablet, 2 = Capsule + if (formID == null || (formID != 1 && formID != 2)) return 1; // 1 = Tablet, 2 = Capsule String frequency = Optional.ofNullable(toStr(drug.get("frequency"))).orElse("").toUpperCase(); if (frequency.contains("SINGLE DOSE") || frequency.contains("STAT")) return 1; From b050820859aa26c4c3b3c0eae9a5077424c309b6 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 17:23:46 +0530 Subject: [PATCH 08/33] feat(stoptb): getAll drugs list with form; SOS quantity default - generalOpd/getAll returns a "drugs" list per record (drugID, drugName, itemFormID, drugForm, dose, frequency, duration, durationUnit, qtyPrescribed, instructions) from t_prescribeddrug, so a saved prescription can be shown with form and quantity. Existing fields are unchanged; old free-text records return an empty list. - SOS tablets/capsules without a quantity: 1 per day of duration (1 when no duration) instead of rejecting the save. Co-Authored-By: Claude Opus 5.5 --- .../service/StopTBPrescriptionService.java | 39 ++++++++++++++----- .../flw/service/impl/StopTBServiceImpl.java | 1 + 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java index 8b13682e..c84b6074 100644 --- a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -179,7 +179,7 @@ public Map prescribeAndDispense(Map prescription qty = calculateQuantity(d, items.get(drugID)); if (qty == null) { throw new IllegalArgumentException("qtyPrescribed is required for drugID " + drugID - + " (tablet/capsule quantity needs frequency and duration; not possible for SOS)"); + + " (tablet/capsule quantity needs frequency and duration)"); } d.put("qtyPrescribed", qty); // saved in t_prescribeddrug.QtyPrescribed } @@ -344,6 +344,7 @@ public List> getStandardOpdRecords(Integer providerServiceMa m.put("dosage", new StringJoiner(", ")); m.put("frequency", new StringJoiner(", ")); m.put("duration", new StringJoiner(", ")); + m.put("drugs", new ArrayList>()); m.put("notes", null); m.put("createdBy", v[3]); m.put("createdDate", v[4]); @@ -366,9 +367,12 @@ public List> getStandardOpdRecords(Integer providerServiceMa latest(m, r[2]); } - for (Object o : em.createNativeQuery("SELECT VisitCode, GenericDrugName, Dose, Frequency, Duration, DuartionUnit, " - + "LastModDate FROM db_iemr.t_prescribeddrug WHERE VisitCode IN (:codes) AND DrugID IS NOT NULL " - + "AND Deleted = false ORDER BY PrescribedDrugID") + for (Object o : em.createNativeQuery("SELECT d.VisitCode, d.GenericDrugName, d.Dose, d.Frequency, d.Duration, " + + "d.DuartionUnit, d.LastModDate, d.DrugID, i.ItemFormID, d.DrugForm, d.QtyPrescribed, " + + "d.SpecialInstruction FROM db_iemr.t_prescribeddrug d " + + "LEFT JOIN db_iemr.m_item i ON i.ItemID = d.DrugID " + + "WHERE d.VisitCode IN (:codes) AND d.DrugID IS NOT NULL AND d.Deleted = false " + + "ORDER BY d.PrescribedDrugID") .setParameter("codes", chunk).getResultList()) { Object[] r = (Object[]) o; Map m = byVisit.get(((Number) r[0]).longValue()); @@ -377,6 +381,20 @@ public List> getStandardOpdRecords(Integer providerServiceMa ((StringJoiner) m.get("frequency")).add(r[3] != null ? r[3].toString() : ""); ((StringJoiner) m.get("duration")).add(r[4] == null ? "" : r[5] == null ? r[4].toString() : r[4] + " " + r[5]); latest(m, r[6]); + + // Full prescription per drug, so a saved record can show form, quantity and instructions. + Map drug = new LinkedHashMap<>(); + drug.put("drugID", ((Number) r[7]).intValue()); + drug.put("drugName", r[1]); + drug.put("itemFormID", r[8] != null ? ((Number) r[8]).intValue() : null); + drug.put("drugForm", r[9]); + drug.put("dose", r[2]); + drug.put("frequency", r[3]); + drug.put("duration", r[4] != null ? toInt(r[4]) : null); + drug.put("durationUnit", r[5]); + drug.put("qtyPrescribed", r[10] != null ? ((Number) r[10]).intValue() : null); + drug.put("instructions", r[11]); + ((List>) m.get("drugs")).add(drug); } // Latest prescription instruction per visit is the visit's note. @@ -456,9 +474,9 @@ private long insertPrescription(Long beneficiaryRegID, BenVisitDetail visit, Str } /** - * Units to give when the app sends no quantity. Tablets/capsules: doses per day x days. Other forms - * (syrup, cream, injection, drops...): 1 bottle/tube/vial. Null when a tablet/capsule quantity cannot be - * worked out (SOS, missing frequency or duration). + * Units to give when the app sends no quantity. Tablets/capsules: doses per day x days (SOS = 1 per day, + * or 1 when no duration). Other forms (syrup, cream, injection, drops...): 1 bottle/tube/vial. Null when a + * tablet/capsule quantity cannot be worked out (missing/unknown frequency or missing duration). */ private Integer calculateQuantity(Map drug, Object[] item) { Integer formID = item[7] != null ? ((Number) item[7]).intValue() : null; @@ -467,7 +485,8 @@ private Integer calculateQuantity(Map drug, Object[] item) { if (frequency.contains("SINGLE DOSE") || frequency.contains("STAT")) return 1; Integer duration = toInt(drug.get("duration")); - if (duration == null || duration <= 0) return null; + boolean sos = frequency.contains("SOS"); + if (duration == null || duration <= 0) return sos ? 1 : null; String unit = Optional.ofNullable(toStr(drug.get("durationUnit"))).orElse("Day").toLowerCase(); int days = unit.startsWith("week") ? duration * 7 : unit.startsWith("month") ? duration * 30 : unit.startsWith("year") ? duration * 365 : duration; @@ -477,8 +496,8 @@ private Integer calculateQuantity(Map drug, Object[] item) { if (frequency.contains("QID") || frequency.contains("FOUR")) perDay = 4; else if (frequency.contains("TID") || frequency.contains("THRICE")) perDay = 3; else if (frequency.contains("BD") || frequency.contains("TWICE")) perDay = 2; - else if (frequency.contains("OD") || frequency.contains("ONCE DAILY")) perDay = 1; - else return null; // SOS or unknown + else if (frequency.contains("OD") || frequency.contains("ONCE DAILY") || sos) perDay = 1; // SOS: up to 1 a day + else return null; // unknown frequency return perDay * days; } 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 5d451ccf..0c2a8bdf 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -491,6 +491,7 @@ private Map opdToMap(StopTBGeneralOpd o) { m.put("dosage", o.getDosage()); m.put("frequency", o.getFrequency()); m.put("duration", o.getDuration()); + m.put("drugs", Collections.emptyList()); // old free-text records have no per-drug details m.put("notes", o.getNotes()); m.put("createdBy", o.getCreatedBy()); m.put("createdDate", o.getCreatedDate()); From f3a8a1dc518cbde55f2ba2febffb6cc8b6232858 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 15 Sep 2026 16:37:55 +0530 Subject: [PATCH 09/33] Dropping duplicate columns in table tb_stoptb_general_examination --- .../domain/iemr/StopTBGeneralExamination.java | 17 ++++------------- .../flw/service/impl/StopTBServiceImpl.java | 17 +++++++++-------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java b/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java index 9972d916..59b271e1 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java +++ b/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java @@ -72,19 +72,10 @@ public class StopTBGeneralExamination { @Column(name = "clubbing") private String clubbing; - // JSON arrays serialised from mobile - @Column(name = "key_population_risk_factor_ids", columnDefinition = "TEXT") - private String keyPopulationRiskFactorIds; - - @Column(name = "key_population_risk_factors", columnDefinition = "TEXT") - private String keyPopulationRiskFactors; - - @Column(name = "hiv_status_id") - private Integer hivStatusId; - - // "Positive" | "Reactive" | "Negative" | "Unknown" - @Column(name = "hiv_status") - private String hivStatus; + // key_population_risk_factor_ids/_factors and hiv_status_id/hiv_status were removed from + // this table — TB Screening (TBScreening) is the single source of truth for these fields + // (see StopTBServiceImpl#examToMap, and the AMRIT-DB migration that backfilled tb_screening + // from this table's historical data before dropping the columns here). @Column(name = "referral_to_hwc_needed_id") private Integer referralToHWCNeededId; diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index 0c2a8bdf..360edb60 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -138,10 +138,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); @@ -207,10 +205,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 d5f07a298deeda0b4c2df16c98ac32e6c444612d Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 22 Sep 2026 10:37:07 +0530 Subject: [PATCH 10/33] Adding migration applier and removing column migration file --- .../domain/iemr/DynamicFormMigrationLog.java | 53 +++ .../iemr/flw/domain/iemr/QuestionOption.java | 5 + .../iemr/flw/domain/iemr/SectionQuestion.java | 5 + .../com/iemr/flw/dto/iemr/FormSectionDTO.java | 2 +- .../iemr/flw/dto/iemr/QuestionOptionDTO.java | 3 +- .../iemr/flw/dto/iemr/SectionQuestionDTO.java | 2 +- .../iemr/DynamicFormMigrationLogRepo.java | 34 ++ .../iemr/flw/repo/iemr/FormSectionRepo.java | 13 + .../flw/repo/iemr/OptionConditionRepo.java | 8 + .../flw/repo/iemr/QuestionOptionRepo.java | 17 +- .../flw/repo/iemr/QuestionValidationRepo.java | 6 + .../flw/repo/iemr/SectionQuestionRepo.java | 20 +- .../DynamicFormMigrationApplier.java | 49 +++ .../migration/DynamicFormMigrationRunner.java | 73 ++++ .../migration/FormStructureMigration.java | 78 +++++ .../migration/V001_ReplaceTfuAdrOptions.java | 104 ++++++ .../DynamicFormReconciliationService.java | 101 ++++++ .../DynamicFormReconciliationServiceImpl.java | 324 ++++++++++++++++++ 18 files changed, 891 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java create mode 100644 src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java diff --git a/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java new file mode 100644 index 00000000..1ff0158d --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java @@ -0,0 +1,53 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.sql.Timestamp; + +/** + * One row per applied {@code com.iemr.flw.seeder.migration.FormStructureMigration}, keyed by its + * stable migrationId — lets {@code DynamicFormMigrationRunner} skip a migration it has already run. + */ +@Entity +@Table(name = "t_dynamic_form_migration_log", schema = "db_iemr") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DynamicFormMigrationLog { + + @Id + @Column(name = "migrationId", length = 50) + private String migrationId; + + @CreationTimestamp + @Column(name = "appliedAt", nullable = false, updatable = false) + private Timestamp appliedAt; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index 124ea6fa..645fa0d1 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -76,6 +76,11 @@ public class QuestionOption { @Column(name = "displayOrder", nullable = false) private Integer displayOrder; + /** Soft-delete flag: false means removed from its question. Never hard-deleted — historical + * QuestionResponse rows reference optionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 1aa324cb..a50418d0 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -98,6 +98,11 @@ public class SectionQuestion { @Column(name = "containsPii", nullable = false) private Boolean containsPii = false; + /** Soft-delete flag: false means unlinked from its section. Never hard-deleted — historical + * QuestionResponse rows reference questionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java index 6bb10795..0d950d2a 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java @@ -55,7 +55,7 @@ public class FormSectionDTO { private Boolean isRequired = true; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at that position (existing siblings shift up). */ private Integer displayOrder; private Boolean hasSubmitButton = false; diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java index 6682404a..d1547c3b 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java @@ -23,7 +23,6 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -51,7 +50,7 @@ public class QuestionOptionDTO { private String optionValueHindi; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at that position (existing siblings shift up). */ private Integer displayOrder; @Valid diff --git a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java index 11a81430..2198ccc4 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java @@ -56,7 +56,7 @@ public class SectionQuestionDTO { private Boolean isMandatory = true; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at (or move to) that position (existing siblings shift). */ private Integer displayOrder; private Integer maxLength; diff --git a/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java new file mode 100644 index 00000000..9f993076 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java @@ -0,0 +1,34 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Repository for applied form-structure migration records. + * Id type is the migrationId String itself — existsById/save need no custom methods. + */ +@Repository +public interface DynamicFormMigrationLogRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java index 9f135959..fa6acdf1 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java @@ -24,12 +24,14 @@ import com.iemr.flw.domain.iemr.FormSection; import com.iemr.flw.masterEnum.SectionPhase; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for form sections. @@ -40,6 +42,17 @@ public interface FormSectionRepo extends JpaRepository { List findByFormVersion_VersionIdOrderByDisplayOrderAsc(Long versionId); + Optional findByFormVersion_VersionIdAndSectionUuid(Long versionId, String sectionUuid); + + Optional findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(Long versionId); + + /** Shifts displayOrder by delta for every section in [from, to] within a version — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE FormSection s SET s.displayOrder = s.displayOrder + :delta " + + "WHERE s.formVersion.versionId = :versionId AND s.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("versionId") Long versionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + @Query("SELECT fs.formVersion.versionId, COUNT(fs) FROM FormSection fs " + "WHERE fs.formVersion.versionId IN :versionIds " + "AND fs.sectionPhase = :sectionPhase " + diff --git a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java index 63341889..c82040ea 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java @@ -29,6 +29,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for option conditions. @@ -38,6 +39,13 @@ public interface OptionConditionRepo extends JpaRepository findByQuestionOption_OptionId(Long optionId); + /** No surrogate natural key on conditions — matched by (actionType, target) within an option. */ + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + Long optionId, String actionType, String targetQuestionUuid); + + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + Long optionId, String actionType, String targetSectionUuid); + @Query("SELECT oc.targetQuestion.questionId FROM OptionCondition oc " + "WHERE oc.questionOption.sectionQuestion.formSection.formVersion.versionId = :versionId " + "AND oc.targetQuestion IS NOT NULL") diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java index f7512055..613abb26 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.QuestionOption; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question options. @@ -38,11 +40,24 @@ public interface QuestionOptionRepo extends JpaRepository List findBySectionQuestion_QuestionIdOrderByDisplayOrderAsc(Long questionId); + Optional findBySectionQuestion_QuestionIdAndOptionValue(Long questionId, String optionValue); + + Optional findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(Long questionId); + + /** Shifts displayOrder by delta for every option in [from, to] within a question — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE QuestionOption o SET o.displayOrder = o.displayOrder + :delta " + + "WHERE o.sectionQuestion.questionId = :questionId AND o.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("questionId") Long questionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all options for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. + * Excludes removed (isActive=false) options — used only by read paths; reconciliation matching + * uses the natural-key finder above, which must see inactive rows too. */ @Query("SELECT o FROM QuestionOption o JOIN FETCH o.sectionQuestion " - + "WHERE o.sectionQuestion.questionId IN :questionIds ORDER BY o.displayOrder ASC") + + "WHERE o.sectionQuestion.questionId IN :questionIds AND o.isActive = true ORDER BY o.displayOrder ASC") List findByQuestionIdsOrderByDisplayOrderAsc(@Param("questionIds") Collection questionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java index d1cf4187..ad8be0fa 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java @@ -22,6 +22,7 @@ package com.iemr.flw.repo.iemr; import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.masterEnum.ValidationType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -29,6 +30,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question validation rules. @@ -38,6 +40,10 @@ public interface QuestionValidationRepo extends JpaRepository findBySectionQuestion_QuestionId(Long questionId); + /** No surrogate natural key on validations — matched by the (type, param) content tuple within a question. */ + Optional findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + Long questionId, ValidationType validationType, String validationParam); + /** * Loads all validations for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. diff --git a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java index 6a117e96..ae2a6744 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.SectionQuestion; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for section questions. @@ -38,11 +40,27 @@ public interface SectionQuestionRepo extends JpaRepository findByFormSection_SectionIdOrderByDisplayOrderAsc(Long sectionId); + Optional findByFormSection_SectionIdAndQuestionUuid(Long sectionId, String questionUuid); + + /** Version-scoped lookup — a condition's target question may live in a different section of the same version. */ + Optional findByFormSection_FormVersion_VersionIdAndQuestionUuid(Long versionId, String questionUuid); + + Optional findTopByFormSection_SectionIdOrderByDisplayOrderDesc(Long sectionId); + + /** Shifts displayOrder by delta for every question in [from, to] within a section — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE SectionQuestion q SET q.displayOrder = q.displayOrder + :delta " + + "WHERE q.formSection.sectionId = :sectionId AND q.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("sectionId") Long sectionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all questions for a set of sections in one query. * JOIN FETCH ensures formSection is hydrated so callers can group by sectionId without extra queries. + * Excludes unlinked (isActive=false) questions — used only by read paths; reconciliation matching + * uses the natural-key finders above, which must see inactive rows too. */ @Query("SELECT q FROM SectionQuestion q JOIN FETCH q.formSection " - + "WHERE q.formSection.sectionId IN :sectionIds ORDER BY q.displayOrder ASC") + + "WHERE q.formSection.sectionId IN :sectionIds AND q.isActive = true ORDER BY q.displayOrder ASC") List findBySectionIdsOrderByDisplayOrderAsc(@Param("sectionIds") Collection sectionIds); } diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java new file mode 100644 index 00000000..ae4de9c6 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Applies one {@link FormStructureMigration} and records it as applied, as a single atomic unit. + * Kept as its own bean (rather than a method on {@link DynamicFormMigrationRunner}) so the + * {@code @Transactional} proxy is actually honored — a runner calling this method on itself would + * bypass Spring AOP's self-invocation limitation. + */ +@Service +@RequiredArgsConstructor +public class DynamicFormMigrationApplier { + + private final DynamicFormReconciliationService reconciliationService; + private final DynamicFormMigrationLogRepo migrationLogRepo; + + @Transactional + public void apply(FormStructureMigration migration) { + migration.apply(reconciliationService); + migrationLogRepo.save(new DynamicFormMigrationLog(migration.migrationId(), null)); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java new file mode 100644 index 00000000..2071b61e --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java @@ -0,0 +1,73 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; + +/** + * Applies every {@link FormStructureMigration} bean, in {@link FormStructureMigration#migrationId()} + * order, skipping ones already recorded in {@code t_dynamic_form_migration_log}. + * + * Runs as an {@link ApplicationRunner} rather than another {@code @PostConstruct} specifically so it + * always runs after the 6 form seeders: Spring Boot invokes ApplicationRunners only once the whole + * application context has finished refreshing, which is strictly after every bean's + * {@code @PostConstruct} (the seeders included) has already completed — no {@code @Order} + * coordination needed between the two mechanisms. + * + * A migration that throws propagates out of this loop and aborts startup — a broken migration + * should fail loudly, not be silently skipped, and later migrations shouldn't apply out of order + * behind a failed one. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DynamicFormMigrationRunner implements ApplicationRunner { + + private final List migrations; + private final DynamicFormMigrationLogRepo migrationLogRepo; + private final DynamicFormMigrationApplier applier; + + @Override + public void run(ApplicationArguments args) { + migrations.stream() + .sorted(Comparator.comparing(FormStructureMigration::migrationId)) + .forEach(this::applyIfNeeded); + } + + private void applyIfNeeded(FormStructureMigration migration) { + String id = migration.migrationId(); + if (migrationLogRepo.existsById(id)) { + log.info("Form structure migration '{}' already applied — skipping.", id); + return; + } + applier.apply(migration); + log.info("Form structure migration '{}' applied.", id); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java new file mode 100644 index 00000000..969bb04f --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java @@ -0,0 +1,78 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.service.DynamicFormReconciliationService; + +/** + * One incremental, one-off structural change to a form that has already been created by one of the + * {@code com.iemr.flw.seeder} classes — e.g. "add this new question to that existing section", or + * "add this new option to that existing question". {@code DynamicFormMigrationRunner} discovers + * every Spring bean implementing this interface, sorts them by {@link #migrationId()}, and applies + * each exactly once, recording it in {@code t_dynamic_form_migration_log} so it is skipped on + * every later restart. A migration should never be edited after it has shipped — write a new one + * instead — since the log only records "has this id run", not what it actually did. + * + * Naming convention: {@code V_ShortDescription}, e.g. {@code V001_AddSideEffectsQuestion}, + * zero-padded so lexicographic sort matches numeric order. Give each class a javadoc stating the + * business reason for the change — that, plus the class itself, is the permanent changelog. + * + * Example: + *
{@code
+ * @Component
+ * public class V001_AddSideEffectsQuestionToTbCounselling implements FormStructureMigration {
+ *
+ *     @Override
+ *     public String migrationId() {
+ *         return "V001";
+ *     }
+ *
+ *     @Override
+ *     public void apply(DynamicFormReconciliationService svc) {
+ *         FormVersion version = svc.latestVersion("TB_COUNSELLING")
+ *                 .orElseThrow(() -> new IllegalStateException(
+ *                         "TB_COUNSELLING not found — must run after TbCounsellingFormSeeder"));
+ *         FormSection section = svc.section(version, "TB_SEC_B")
+ *                 .orElseThrow(() -> new IllegalStateException("Section TB_SEC_B not found"));
+ *
+ *         SectionQuestionDTO question = new SectionQuestionDTO();
+ *         question.setQuestionUuid("TB_B_Q7");
+ *         question.setQuestionText("Side effects reported?");
+ *         question.setQuestionType(QuestionType.RADIO);
+ *         question.setOptions(List.of(yesOption, noOption));
+ *         svc.ensureQuestion(section, question);
+ *     }
+ * }
+ * }
+ */ +public interface FormStructureMigration { + + /** Stable id, e.g. "V001" — the migration-log primary key and the sort key for apply order. Never change it once shipped. */ + String migrationId(); + + /** + * Applies this migration's change(s) via the reconciliation service's ensureX methods. + * Should remain safe to re-run (ensureX no-ops on rows that already exist) even though the + * runner normally calls this only once per migrationId, per the tracking log. + */ + void apply(DynamicFormReconciliationService reconciliationService); +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java new file mode 100644 index 00000000..6fc90ff6 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java @@ -0,0 +1,104 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Replaces the TPT Follow Up form's "Adverse Drug Reactions" (TFU_ADR) option list with the + * clinically revised set of symptoms, per the updated ADR checklist supplied for this form. + * + * 7 of the 24 existing options already match the new list exactly (same wording) and are left + * untouched by {@code ensureOption}'s natural-key match: NAUSEA, VOMITING, ABDOMINAL_PAIN, + * DARK_COLOUR_URINE, ITCHING, TINGLING_BURNING_NUMBNESS_HANDS_FEET, FLU_LIKE_SYNDROME. The other + * 17 old options are no longer part of the revised list and are soft-removed (never hard-deleted — + * historical responses may reference them); the 13 new options are added. + */ +@Component +public class V001_ReplaceTfuAdrOptions implements FormStructureMigration { + + private static final List REMOVED_OPTION_VALUES = List.of( + "UNKNOWN", + "FLU_LIKE_SYNDROME_WITH_DIZZINESS_HEADACHE", + "YELLOWISH_DISCOLORATION_SKIN_EYES", + "DISCOLORATION_BODY_FLUIDS", + "PALE_STOOL", + "SKIN_RASH", + "SLEEPINESS_LETHARGY", + "PERSISTENT_UNFORMED_WATERY_STOOLS", + "MENTAL_CHANGES_SIGNS_BLEEDING", + "CONVULSIONS", + "ANAEMIA", + "ARTHRALGIA", + "DECREASED_APPETITE", + "HYPOTENSION_SYNCOPE", + "CONJUCTIVITIES", + "SHOCK", + "NONE_REPORTED"); + + @Override + public String migrationId() { + return "V001"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("TPT_FOLLOW_UP") + .orElseThrow(() -> new IllegalStateException( + "TPT_FOLLOW_UP not found — must run after TptFollowUpFormSeeder")); + FormSection section = svc.section(version, "TFU_SEC_2") + .orElseThrow(() -> new IllegalStateException("Section TFU_SEC_2 not found")); + SectionQuestion question = svc.question(section, "TFU_ADR") + .orElseThrow(() -> new IllegalStateException("Question TFU_ADR not found")); + + REMOVED_OPTION_VALUES.forEach(value -> svc.option(question, value) + .ifPresent(opt -> svc.removeOption(opt.getOptionId()))); + + addOption(svc, question, "Yellowness of Skin", "YELLOWNESS_OF_SKIN"); + addOption(svc, question, "Loose motions >4 times in a day", "LOOSE_MOTIONS_GT4_PER_DAY"); + addOption(svc, question, "Rashes", "RASHES"); + addOption(svc, question, "Pain in Joints", "PAIN_IN_JOINTS"); + addOption(svc, question, "Impaired vision: Pain, Blurring of vision, Disturbance in colour vision", "IMPAIRED_VISION"); + addOption(svc, question, "Swelling of face or legs, Less or no urine", "SWELLING_FACE_LEGS_LESS_NO_URINE"); + addOption(svc, question, "Seeing abnormal things, changes of thoughts, suicidal thoughts", "ABNORMAL_THOUGHTS_SUICIDAL"); + addOption(svc, question, "Tiredness, lethargy, headache, giddiness, pale look, palpitations", "TIREDNESS_LETHARGY_GIDDINESS_PALPITATIONS"); + addOption(svc, question, "Ringing in ears, Loss of hearing, dizziness and loss of balance leading to recurrent fall", "TINNITUS_HEARING_LOSS_DIZZINESS_FALL"); + addOption(svc, question, "Slowness of activities, Swelling of face or neck, dis-appropriate weight gain", "SLOWNESS_FACIAL_NECK_SWELLING_WEIGHT_GAIN"); + addOption(svc, question, "Pain and swelling in muscles and Tendons, difficulty in movement", "MUSCLE_TENDON_PAIN_SWELLING"); + addOption(svc, question, "Convulsion", "CONVULSION"); + addOption(svc, question, "Orange and red color of urine, sweat, sputum, saliva or tears", "ORANGE_RED_DISCOLORATION_BODY_FLUIDS"); + } + + private void addOption(DynamicFormReconciliationService svc, SectionQuestion question, String label, String value) { + QuestionOptionDTO dto = new QuestionOptionDTO(); + dto.setOptionLabel(label); + dto.setOptionValue(value); + svc.ensureOption(question, dto); + } +} diff --git a/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java b/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java new file mode 100644 index 00000000..f558f007 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java @@ -0,0 +1,101 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.service; + +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.OptionCondition; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; + +import java.util.Optional; + +/** + * Idempotent, in-place reconciliation for dynamic form structure — inserts whatever is missing + * from the current latest {@link FormVersion} and leaves existing rows untouched. Never creates a + * new version (unlike {@link DynamicFormDefinitionService#updateForm}, which always clones one). + * + * Used both by the startup seeders (walking their full hardcoded DTO tree every run) and by the + * granular structure endpoints (a single section/question/option at a time). + * + * Each {@code ensureX} method operates on exactly one level — callers walk the tree themselves and + * call the next level down, since only the caller knows whether it's safe yet to wire cross-sibling + * references (see {@link #ensureCondition}). + */ +public interface DynamicFormReconciliationService { + + /** Finds the form by formUuid, or creates it. If found inactive, reactivates it (touches no other field). */ + DynamicForm ensureForm(DynamicFormDTO dto); + + /** Finds the form's isLatest=true version, or creates version 1 if none exists yet. */ + FormVersion ensureLatestVersion(DynamicForm form); + + /** Finds a section by sectionUuid within the version, or creates it. */ + FormSection ensureSection(FormVersion version, FormSectionDTO dto); + + /** Finds a question by questionUuid within the section, or creates it (a section's question is "linked" the moment it's created — no separate link step exists). */ + SectionQuestion ensureQuestion(FormSection section, SectionQuestionDTO dto); + + /** Finds an option by optionValue within the question, or creates it. */ + QuestionOption ensureOption(SectionQuestion question, QuestionOptionDTO dto); + + /** Finds a validation by (validationType, validationParam) within the question, or creates it — validations carry no natural key of their own. */ + QuestionValidation ensureValidation(SectionQuestion question, QuestionValidationDTO dto); + + /** + * Finds a condition by (actionType, target) within the option, or creates it, resolving + * targetQuestionUuid/targetSectionUuid against the given version. Call only once every sibling + * section/question this condition might target already exists as a row. + */ + OptionCondition ensureCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version); + + /** Updates a question's scalar fields only (options/validations are managed by their own endpoints). A displayOrder change moves the question among its siblings, shifting them accordingly. */ + SectionQuestion updateQuestion(Long questionId, SectionQuestionDTO dto); + + /** Soft-deletes (isActive=false) a question — never hard-deleted; historical responses may reference it. */ + void unlinkQuestion(Long questionId); + + /** Soft-deletes (isActive=false) an option — never hard-deleted; historical responses may reference it. */ + void removeOption(Long optionId); + + // ── Read-only lookups, for FormStructureMigration classes to locate what they're changing ── + + /** Finds the given form's current latest version, if the form exists at all. */ + Optional latestVersion(String formUuid); + + /** Finds a section by sectionUuid within the given version. */ + Optional section(FormVersion version, String sectionUuid); + + /** Finds a question by questionUuid within the given section. */ + Optional question(FormSection section, String questionUuid); + + /** Finds an option by optionValue within the given question. */ + Optional option(SectionQuestion question, String optionValue); +} diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java new file mode 100644 index 00000000..d5de312b --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java @@ -0,0 +1,324 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.OptionCondition; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.mapper.DynamicFormMapper; +import com.iemr.flw.repo.iemr.DynamicFormRepo; +import com.iemr.flw.repo.iemr.FormSectionRepo; +import com.iemr.flw.repo.iemr.FormVersionRepo; +import com.iemr.flw.repo.iemr.OptionConditionRepo; +import com.iemr.flw.repo.iemr.QuestionOptionRepo; +import com.iemr.flw.repo.iemr.QuestionValidationRepo; +import com.iemr.flw.repo.iemr.SectionQuestionRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.IntConsumer; +import java.util.function.Supplier; + +/** + * In-place reconciliation for dynamic form structure — see {@link DynamicFormReconciliationService}. + * Standalone: does not depend on {@link DynamicFormDefinitionServiceImpl}'s private helpers, since + * that service's write path (full version clone) is a deliberately different mechanism from this one. + * + * @author Piramal Swasthya + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class DynamicFormReconciliationServiceImpl implements DynamicFormReconciliationService { + + private final DynamicFormRepo formRepo; + private final FormVersionRepo versionRepo; + private final FormSectionRepo sectionRepo; + private final SectionQuestionRepo questionRepo; + private final QuestionOptionRepo optionRepo; + private final QuestionValidationRepo validationRepo; + private final OptionConditionRepo conditionRepo; + private final DynamicFormMapper mapper; + + // ── Form / Version ──────────────────────────────────────────────────────── + + @Override + @Transactional + public DynamicForm ensureForm(DynamicFormDTO dto) { + Optional existing = formRepo.findByFormUuid(dto.getFormUuid()); + if (existing.isPresent()) { + DynamicForm form = existing.get(); + if (Boolean.FALSE.equals(form.getIsActive())) { + form.setIsActive(true); + form = formRepo.save(form); + log.info("Form '{}' was inactive — reactivated.", dto.getFormUuid()); + } + return form; + } + DynamicForm form = mapper.toEntity(dto); + form.setFormId(null); + return formRepo.save(form); + } + + @Override + @Transactional + public FormVersion ensureLatestVersion(DynamicForm form) { + return versionRepo.findByDynamicForm_FormIdAndIsLatest(form.getFormId(), true) + .orElseGet(() -> { + FormVersion version = new FormVersion(); + version.setDynamicForm(form); + version.setVersionNumber(1); + version.setIsLatest(true); + return versionRepo.save(version); + }); + } + + // ── Section / Question / Option / Validation / Condition ───────────────── + + @Override + @Transactional + public FormSection ensureSection(FormVersion version, FormSectionDTO dto) { + return sectionRepo.findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), dto.getSectionUuid()) + .orElseGet(() -> createSection(version, dto)); + } + + @Override + @Transactional + public SectionQuestion ensureQuestion(FormSection section, SectionQuestionDTO dto) { + return questionRepo.findByFormSection_SectionIdAndQuestionUuid(section.getSectionId(), dto.getQuestionUuid()) + .orElseGet(() -> createQuestion(section, dto)); + } + + @Override + @Transactional + public QuestionOption ensureOption(SectionQuestion question, QuestionOptionDTO dto) { + return optionRepo.findBySectionQuestion_QuestionIdAndOptionValue(question.getQuestionId(), dto.getOptionValue()) + .orElseGet(() -> createOption(question, dto)); + } + + @Override + @Transactional + public QuestionValidation ensureValidation(SectionQuestion question, QuestionValidationDTO dto) { + return validationRepo.findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + question.getQuestionId(), dto.getValidationType(), dto.getValidationParam()) + .orElseGet(() -> createValidation(question, dto)); + } + + @Override + @Transactional + public OptionCondition ensureCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version) { + Optional existing; + if (dto.getTargetQuestionUuid() != null) { + existing = conditionRepo.findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + option.getOptionId(), dto.getActionType(), dto.getTargetQuestionUuid()); + } else if (dto.getTargetSectionUuid() != null) { + existing = conditionRepo.findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + option.getOptionId(), dto.getActionType(), dto.getTargetSectionUuid()); + } else { + existing = Optional.empty(); + } + return existing.orElseGet(() -> createCondition(option, dto, version)); + } + + // ── Update / Remove ──────────────────────────────────────────────────────── + + @Override + @Transactional + public SectionQuestion updateQuestion(Long questionId, SectionQuestionDTO dto) { + SectionQuestion question = questionRepo.findById(questionId) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + questionId)); + question.setQuestionText(dto.getQuestionText()); + question.setQuestionTextHindi(dto.getQuestionTextHindi()); + question.setQuestionType(dto.getQuestionType()); + question.setIsMandatory(dto.getIsMandatory()); + question.setMaxLength(dto.getMaxLength()); + question.setDefaultValue(dto.getDefaultValue()); + question.setContainsPii(dto.getContainsPii()); + if (dto.getDisplayOrder() != null && !dto.getDisplayOrder().equals(question.getDisplayOrder())) { + moveQuestion(question, dto.getDisplayOrder()); + } + return questionRepo.save(question); + } + + @Override + @Transactional + public void unlinkQuestion(Long questionId) { + SectionQuestion question = questionRepo.findById(questionId) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + questionId)); + question.setIsActive(false); + questionRepo.save(question); + } + + @Override + @Transactional + public void removeOption(Long optionId) { + QuestionOption option = optionRepo.findById(optionId) + .orElseThrow(() -> new NoSuchElementException("Option not found: " + optionId)); + option.setIsActive(false); + optionRepo.save(option); + } + + // ── Read-only lookups ────────────────────────────────────────────────────── + + @Override + public Optional latestVersion(String formUuid) { + return formRepo.findByFormUuid(formUuid) + .flatMap(form -> versionRepo.findByDynamicForm_FormIdAndIsLatest(form.getFormId(), true)); + } + + @Override + public Optional section(FormVersion version, String sectionUuid) { + return sectionRepo.findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), sectionUuid); + } + + @Override + public Optional question(FormSection section, String questionUuid) { + return questionRepo.findByFormSection_SectionIdAndQuestionUuid(section.getSectionId(), questionUuid); + } + + @Override + public Optional option(SectionQuestion question, String optionValue) { + return optionRepo.findBySectionQuestion_QuestionIdAndOptionValue(question.getQuestionId(), optionValue); + } + + // ── Create helpers ───────────────────────────────────────────────────────── + + private FormSection createSection(FormVersion version, FormSectionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> sectionRepo.findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(version.getVersionId()) + .map(FormSection::getDisplayOrder), + from -> sectionRepo.shiftDisplayOrder(version.getVersionId(), from, Integer.MAX_VALUE, 1)); + FormSection section = mapper.toEntity(dto); + section.setSectionId(null); + section.setFormVersion(version); + section.setDisplayOrder(order); + section.setQuestions(new ArrayList<>()); + return sectionRepo.save(section); + } + + private SectionQuestion createQuestion(FormSection section, SectionQuestionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> questionRepo.findTopByFormSection_SectionIdOrderByDisplayOrderDesc(section.getSectionId()) + .map(SectionQuestion::getDisplayOrder), + from -> questionRepo.shiftDisplayOrder(section.getSectionId(), from, Integer.MAX_VALUE, 1)); + SectionQuestion question = mapper.toEntity(dto); + question.setQuestionId(null); + question.setFormSection(section); + question.setDisplayOrder(order); + question.setOptions(new ArrayList<>()); + question.setValidations(new ArrayList<>()); + return questionRepo.save(question); + } + + private QuestionOption createOption(SectionQuestion question, QuestionOptionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> optionRepo.findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(question.getQuestionId()) + .map(QuestionOption::getDisplayOrder), + from -> optionRepo.shiftDisplayOrder(question.getQuestionId(), from, Integer.MAX_VALUE, 1)); + QuestionOption option = mapper.toEntity(dto); + option.setOptionId(null); + option.setSectionQuestion(question); + option.setDisplayOrder(order); + option.setConditions(new ArrayList<>()); + return optionRepo.save(option); + } + + private QuestionValidation createValidation(SectionQuestion question, QuestionValidationDTO dto) { + QuestionValidation validation = mapper.toEntity(dto); + validation.setValidationId(null); + validation.setSectionQuestion(question); + return validationRepo.save(validation); + } + + private OptionCondition createCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version) { + OptionCondition condition = mapper.toEntity(dto); + condition.setConditionId(null); + condition.setQuestionOption(option); + + if (dto.getTargetQuestionUuid() != null) { + condition.setTargetQuestion(questionRepo + .findByFormSection_FormVersion_VersionIdAndQuestionUuid(version.getVersionId(), dto.getTargetQuestionUuid()) + .orElseThrow(() -> new NoSuchElementException( + "targetQuestionUuid not found in this form version: " + dto.getTargetQuestionUuid()))); + } else if (dto.getTargetQuestionId() != null) { + condition.setTargetQuestion(questionRepo.findById(dto.getTargetQuestionId()) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + dto.getTargetQuestionId()))); + } + + if (dto.getTargetSectionUuid() != null) { + condition.setTargetSection(sectionRepo + .findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), dto.getTargetSectionUuid()) + .orElseThrow(() -> new NoSuchElementException( + "targetSectionUuid not found in this form version: " + dto.getTargetSectionUuid()))); + } else if (dto.getTargetSectionId() != null) { + condition.setTargetSection(sectionRepo.findById(dto.getTargetSectionId()) + .orElseThrow(() -> new NoSuchElementException("Section not found: " + dto.getTargetSectionId()))); + } + + return conditionRepo.save(condition); + } + + /** + * Omitted displayOrder → append after the current max sibling (no shift needed). + * Explicit displayOrder → push every sibling at or after that position up by one, then use it as-is. + */ + private int resolveOrder(Integer requested, Supplier> currentMaxSupplier, + IntConsumer shiftFromInclusive) { + if (requested == null) { + return currentMaxSupplier.get().orElse(0) + 1; + } + shiftFromInclusive.accept(requested); + return requested; + } + + /** + * Moves a question already at oldPos to newPos within its section, shifting the siblings in + * between to keep displayOrder contiguous — same three-branch logic a full reorder needs + * regardless of direction. + */ + private void moveQuestion(SectionQuestion question, int newPos) { + int oldPos = question.getDisplayOrder(); + Long sectionId = question.getFormSection().getSectionId(); + if (newPos < oldPos) { + questionRepo.shiftDisplayOrder(sectionId, newPos, oldPos - 1, 1); + } else { + questionRepo.shiftDisplayOrder(sectionId, oldPos + 1, newPos, -1); + } + question.setDisplayOrder(newPos); + } +} From 9d0e7cc32c73caa40d19b6f9b49814387f98fdab Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 08:27:23 +0530 Subject: [PATCH 11/33] 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 +- .../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 +- 21 files changed, 781 insertions(+), 13 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 28f50859..b645c974 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 5902aa21..62a5f646 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 50af2b38..fbb12ce7 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 b7d41dc1..ebfe9ac3 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 59b271e1..9972d916 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 0d950d2a..6bb10795 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 d1547c3b..6682404a 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 2198ccc4..11a81430 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 931e2469..ed789158 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/iemr/FormSectionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java index fa6acdf1..d3345f66 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 c82040ea..7ec5cad5 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 ad8be0fa..17e1ea61 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 00000000..836c1b1e --- /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 00000000..ba69d37b --- /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 00000000..24db1f45 --- /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 00000000..11a6a3d0 --- /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 00000000..822ea086 --- /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 00000000..3486eea0 --- /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 00000000..7db08a38 --- /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 c40b65d7..638f44b0 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 360edb60..64bc065b 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -138,8 +138,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 e9d49bbbdf229974e06d5b576d178574f016fb2f Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 10:44:33 +0530 Subject: [PATCH 12/33] 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 1f654e23..1d3e8872 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.9.2 + 3.8.4 war FLW-API From 65825aa216c5b77caa247662ffd1445fb646838a Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 10:56:44 +0530 Subject: [PATCH 13/33] 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 64bc065b..0c2a8bdf 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -207,13 +207,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 4dc14b6c8c4094d86864e7493ff9f7ff2109ba72 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 09:37:38 +0530 Subject: [PATCH 14/33] 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 c9ecb63c..9f401d39 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 551e93a6..9f7abac5 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 00000000..78c7cd64 --- /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 c7aae24d..ed2d92d5 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 638f44b0..e61bc5c0 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 bddbd41afd0954e14c26299ac57f11781f36d62f Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 13:00:59 +0530 Subject: [PATCH 15/33] 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 d3345f66..66192716 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 613abb26..0d8e8796 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 ae2a6744..52099366 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 3fb7d2a0e11bb80a24c909b3dd3e594782337ecc Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Thu, 24 Sep 2026 13:17:09 +0530 Subject: [PATCH 16/33] 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 1d3e8872..5fc0bc73 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.8.4 + 3.12.0 war FLW-API From b80b4a6f9208571a35aae9fd39fe70a1a2ff55f8 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 28 Sep 2026 19:23:46 +0530 Subject: [PATCH 17/33] 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 | 271 ++++++++++++------ .../impl/DiagnosticPollSchedulerService.java | 20 +- 19 files changed, 346 insertions(+), 164 deletions(-) create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 0bd9638b..ad996282 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -68,6 +68,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=@env.DIAGNOSTIC_EMRLITE_XRAY_USERNAME@ diagnostic.emrlite.xray.password=@env.DIAGNOSTIC_EMRLITE_XRAY_PASSWORD@ @@ -78,6 +79,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=@env.DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME@ diagnostic.emrlite.truenat.password=@env.DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 2b19223c..b37a8e00 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -67,6 +67,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} @@ -77,6 +78,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 9238d8be..9583947c 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 8eb9476b..1725b5af 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -37,10 +37,11 @@ public DiagnosticOrderController(DiagnosticOrderService diagnosticOrderService) @PostMapping("/order/push") @Operation(summary = "Push a diagnostic order to the configured provider") - public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { + public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrder order = diagnosticOrderService.createAndPushOrder(request); + DiagnosticOrder order = diagnosticOrderService.createAndPushOrderByUser(request, jwtToken); response.setResponse(new Gson().toJson(order)); } catch (Exception e) { logger.error("Error in pushOrder: {}", e.getMessage()); @@ -128,14 +129,17 @@ public String pollOrder(@RequestParam Long beneficiaryId, @RequestParam String o } @PostMapping("/order/manualResult") - @Operation(summary = "Manually submit a diagnostic result for a beneficiary's latest order of the given " - + "type, for use when no vendor device is integrated. Stored as-is; tbPresence, tbConfidence, and " - + "drugResistancePresence are left null since nothing derives them. Rejected if that order is " - + "already COMPLETED.") - public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request) { + @Operation(summary = "Exactly one of resultSummary/reasonToClose is required. resultSummary: manually submit " + + "a diagnostic result for a beneficiary's latest order of the given type, for use when no vendor " + + "device is integrated. Stored as-is; tbPresence, tbConfidence, and drugResistancePresence are left " + + "null since nothing derives them. Rejected if that order is already COMPLETED. reasonToClose: close " + + "the order instead (same behavior as reasonToClose on /order/push), sourcing patient/orderEvent " + + "from the beneficiary's existing order for this type.") + public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request); + DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request, jwtToken); response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in submitManualResult: {}", e.getMessage()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java index aa89012e..19cbd39b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -8,8 +8,7 @@ import java.sql.Timestamp; @Entity -@Table(name = "tb_diagnostic_order", schema = "db_iemr", - uniqueConstraints = @UniqueConstraint(columnNames = {"beneficiary_id", "visitCode", "order_type"})) +@Table(name = "tb_diagnostic_order", schema = "db_iemr") @Data public class DiagnosticOrder { @@ -53,12 +52,15 @@ public class DiagnosticOrder { @Column(name = "error_message", columnDefinition = "TEXT") private String errorMessage; - @Column(name = "reason_for_refusal", columnDefinition = "TEXT") - private String reasonForRefusal; + @Column(name = "reason_to_close", columnDefinition = "TEXT") + private String reasonToClose; @Column(name = "push_response_json", columnDefinition = "LONGTEXT") private String pushResponseJson; + @Column(name = "cancel_response_json", columnDefinition = "LONGTEXT") + private String cancelResponseJson; + @Column(name = "patient_first_name", length = 100) private String patientFirstName; @@ -82,6 +84,9 @@ public class DiagnosticOrder { @Column(name = "modified_by") private String modifiedBy; + @Column(name = "manually_entered_by") + private String manuallyEnteredBy; + @UpdateTimestamp @Column(name = "last_mod_date") private Timestamp lastModDate; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java index 6ed0e03f..abf8475e 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -14,15 +14,12 @@ public class DiagnosticOrderRequestDto { @NotNull private Long beneficiaryId; - @NotNull - private Long visitCode; - @NotNull private String orderType; private String orderEvent; - private String reasonForRefusal; + private String reasonToClose; @NotNull @Valid diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java index 92eaa03d..531800f7 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -13,7 +13,7 @@ public class DiagnosticOrderResultDto { private String providerStatus; private String resultSummary; private String errorMessage; - private String reasonForRefusal; + private String reasonToClose; private Boolean tbPresence; private Double tbConfidence; private Boolean drugResistancePresence; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java index 4e18e0bb..88eafcab 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -12,8 +12,7 @@ public class DiagnosticOrderStatusSummaryDto { private List awaitingProviderResult; private List completed; - private List pollingTimedOut; private List failed; - private List refused; + private List closed; private List awaitingManualEntry; } diff --git a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java index 12bd70cd..cd6a10a8 100644 --- a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java @@ -1,6 +1,5 @@ package com.iemr.flw.dto; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.NoArgsConstructor; @@ -15,6 +14,7 @@ public class ManualDiagnosticResultRequestDto { @NotNull private String orderType; - @NotBlank private String resultSummary; + + private String reasonToClose; } diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java new file mode 100644 index 00000000..f025bacb --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java @@ -0,0 +1,14 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticCancelResult { + private boolean success; + private String rawResponseJson; + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java index 9737cba0..47cd3ee3 100644 --- a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java @@ -11,6 +11,16 @@ public interface DiagnosticProvider { DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception; + /** + * Notifies the vendor that this order is being closed out without (or regardless of) a result, + * so it stops tracking/processing it on their end. Only call for an order actually known to the + * vendor (i.e. previously pushed) — calling this for an order that was never pushed is meaningless. + * The raw response is returned (on both success and a provider-level rejection) so the caller can + * persist it; only a transport-level failure (unreachable, timeout) throws instead, since there's + * no response body to return in that case. + */ + DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception; + /** * Lightweight liveness check against the vendor group serving this orderType. Never throws — * returns false on any failure (unreachable, non-2xx, unparsable response, unconfigured URL). diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java index 0aa20d63..754711ce 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -4,6 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -60,6 +61,12 @@ public class EmrLiteProvider implements DiagnosticProvider { @Value("${diagnostic.emrlite.truenat.ping-url}") private String truenatPingUrl; + @Value("${diagnostic.emrlite.xray.cancel-url}") + private String xrayCancelUrl; + + @Value("${diagnostic.emrlite.truenat.cancel-url}") + private String truenatCancelUrl; + @Autowired @Qualifier("emrLiteRestTemplate") private RestTemplate restTemplate; @@ -158,6 +165,34 @@ public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAss tbPresence, tbConfidence, drugResistancePresence); } + @Override + public DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception { + EmrLiteCancelRequest request = new EmrLiteCancelRequest(order.getExternalOrderId(), reason); + String cancelUrl = DiagnosticOrderType.XRAY_CHEST.name().equals(order.getOrderType()) + ? xrayCancelUrl : truenatCancelUrl; + String responseBody; + try { + responseBody = doPost(cancelUrl, gson.toJson(request)); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.warn("Provider HTTP error on order cancel: externalOrderId={}, status={}", + order.getExternalOrderId(), e.getStatusCode()); + return new DiagnosticCancelResult(false, body, "HTTP " + e.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (envelope == null || !envelope.isSuccess()) { + logger.warn("Provider rejected order cancel: externalOrderId={}, message={}", + order.getExternalOrderId(), envelope != null ? envelope.getMessage() : null); + return new DiagnosticCancelResult(false, responseBody, + envelope != null ? envelope.getMessage() : "empty response"); + } + + logger.info("Order cancelled on provider: externalOrderId={}, reason={}", + order.getExternalOrderId(), reason); + return new DiagnosticCancelResult(true, responseBody, null); + } + @Override public boolean checkHealth(DiagnosticOrderType orderType) { String pingUrl = DiagnosticOrderType.XRAY_CHEST.equals(orderType) ? xrayPingUrl : truenatPingUrl; @@ -236,8 +271,8 @@ private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { DiagnosticOrderStatus topLevelStatus = DiagnosticOrderStatus.fromString(result.getStatus()); if (topLevelStatus == DiagnosticOrderStatus.COMPLETED || topLevelStatus == DiagnosticOrderStatus.FAILED) { // Components contradict a terminal top-level status — only the components map may - // resolve to a terminal state; treat this as still in progress. - return DiagnosticOrderStatus.IN_PROGRESS; + // resolve to a terminal state; treat this as still pending. + return DiagnosticOrderStatus.PENDING; } return topLevelStatus; } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java new file mode 100644 index 00000000..51f8320d --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteCancelRequest { + private String externalOrderId; + private String reason; +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java index 9a0a8ec2..ce444fcb 100644 --- a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -5,13 +5,14 @@ public enum DiagnosticOrderStatus { IN_PROGRESS, COMPLETED, FAILED, - CANCELLED, - EXPIRED, - REFUSED, + CLOSED, MANUAL_ENTRY; public static DiagnosticOrderStatus fromString(String value) { if (value == null) return PENDING; + // IN_PROGRESS is kept only as a legacy/historical value (old rows, backward-compat reporting + // queries) — it is never freshly assigned. A vendor reporting "IN_PROGRESS" is just PENDING. + if (IN_PROGRESS.name().equalsIgnoreCase(value)) return PENDING; for (DiagnosticOrderStatus status : values()) { if (status.name().equalsIgnoreCase(value)) { return status; diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index fd7c7289..fa3e79b4 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -16,11 +16,8 @@ public interface DiagnosticOrderRepo extends JpaRepository findByExternalOrderId(String externalOrderId); - @Query("SELECT o FROM DiagnosticOrder o WHERE o.beneficiaryId = :beneficiaryId AND o.visitCode = :visitCode " + - "AND o.orderType = :orderType AND o.deleted = false") - Optional findByBeneficiaryIdAndVisitCodeAndOrderType(@Param("beneficiaryId") Long beneficiaryId, - @Param("visitCode") Long visitCode, - @Param("orderType") String orderType); + Optional findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + Long beneficiaryId, Long visitCode, String orderType); Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( Long beneficiaryId, String orderType); @@ -55,7 +52,7 @@ Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrd // beneficiary with an old terminal order and a new in-flight retest of the same orderType // is bucketed only by the retest, not both. @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status NOT IN ('COMPLETED', 'EXPIRED', 'FAILED', 'REFUSED', 'MANUAL_ENTRY') " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + @@ -74,16 +71,6 @@ List findBeneficiaryIdsAwaitingProviderResult(@Param("orderType") String o List findBeneficiaryIdsCompleted(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'EXPIRED' " + - "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + - "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + - "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + - "AND (:villageId IS NULL OR b.villageID = :villageId) " + - "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsPollingTimedOut(@Param("orderType") String orderType, - @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + "AND o.status = 'FAILED' " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + @@ -95,13 +82,13 @@ List findBeneficiaryIdsFailed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'REFUSED' " + + "AND o.status IN ('CLOSED', 'CANCELLED', 'EXPIRED', 'REFUSED') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsRefused(@Param("orderType") String orderType, + List findBeneficiaryIdsClosed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java index 0bd5207b..250aa143 100644 --- a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -12,7 +12,14 @@ public interface DiagnosticOrderService { - DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception; + DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception; + + /** + * Same create/push logic as createAndPushOrder, attributed to "SYSTEM" instead of a JWT-derived + * user. For callers with no authenticated user in context, e.g. the poll scheduler auto-recreating + * an order after an invalid result. + */ + DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto request) throws Exception; DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult result) throws Exception; @@ -22,6 +29,13 @@ public interface DiagnosticOrderService { DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visitCode) throws Exception; + /** + * Best-effort: tells the vendor this order is closed out (no result coming), so they stop + * tracking it. Never throws — a provider failure is logged and swallowed, since the order's own + * CLOSED status is what actually matters locally. No-op if the order was never pushed to a vendor. + */ + void notifyProviderOrderClosed(DiagnosticOrder order, String reason); + DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderType, Long visitCode); DiagnosticOrder getOrder(Long beneficiaryId, String orderType, Long visitCode) throws Exception; @@ -32,5 +46,5 @@ public interface DiagnosticOrderService { VendorHealthDto checkVendorHealth(String orderType) throws Exception; - DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request) throws Exception; + DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request, String jwtToken) throws Exception; } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java index b0f92ab4..6c0f1f58 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -116,7 +116,7 @@ public DiagnosticDocumentContent fetch(Long beneficiaryId, DiagnosticDocumentTyp String orderType = documentType.impliedOrderType().name(); DiagnosticOrder order; if (visitCode != null) { - order = diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType) + order = diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType) .orElseThrow(() -> new Exception("No diagnostic order found for beneficiaryId=" + beneficiaryId + ", visitCode=" + visitCode + ", orderType=" + orderType)); } else { diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 8c93e055..8aeb045b 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -1,5 +1,6 @@ package com.iemr.flw.service.impl; +import com.iemr.flw.domain.iemr.BenVisitDetail; import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.domain.iemr.DiagnosticResult; import com.iemr.flw.domain.iemr.TBSuspected; @@ -8,6 +9,7 @@ import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; import com.iemr.flw.dto.ManualDiagnosticResultRequestDto; import com.iemr.flw.dto.VendorHealthDto; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -15,15 +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.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; +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; @@ -34,10 +38,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.concurrent.Callable; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; +import java.util.UUID; @Service public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @@ -46,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; @@ -69,38 +67,29 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @Autowired private CampConfigService campConfigService; - private record OrderLockKey(Long beneficiaryId, String orderType) {} - private final LoadingCache orderLocks = CacheBuilder.newBuilder() - .weakValues() - .build(CacheLoader.from(key -> new ReentrantLock())); + @Autowired + private BeneficiaryRepo beneficiaryRepo; - private static final long LOCK_WAIT_SECONDS = 30; + @Autowired + private TBStopVisitService tbStopVisitService; - private T withOrderLock(Long beneficiaryId, String orderType, Callable action) throws Exception { - if (beneficiaryId == null || orderType == null) { - throw new IllegalArgumentException("beneficiaryId and orderType are required"); - } - Lock lock = orderLocks.getUnchecked(new OrderLockKey(beneficiaryId, orderType)); - if (!lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS)) { - throw new IllegalStateException("Another diagnostic order operation is still in progress for beneficiaryId=" - + beneficiaryId + ", orderType=" + orderType + " — please retry shortly"); - } - try { - return action.call(); - } finally { - lock.unlock(); - } + @Autowired + private JwtUtil jwtUtil; + + @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)); } @Override - public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { - DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); - return withOrderLock(request.getBeneficiaryId(), orderType.name(), () -> createAndPushOrderLocked(request)); + public DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto request) throws Exception { + return createAndPushOrder(request, "SYSTEM"); } - private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto request) throws Exception { + 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(); @@ -108,24 +97,43 @@ private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto reque 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() @@ -138,11 +146,11 @@ private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto reque 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); @@ -150,10 +158,7 @@ private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto reque 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); @@ -163,7 +168,7 @@ private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto reque 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()); @@ -171,16 +176,11 @@ private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto reque } 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", 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; @@ -211,16 +211,55 @@ private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto reque 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(); } @@ -242,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()); @@ -262,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()); @@ -302,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())) { @@ -313,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()); @@ -378,9 +444,8 @@ 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 = 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()); + order.setProcessed("N"); + diagnosticOrderRepo.save(order); return null; } DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); @@ -415,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)); } @@ -434,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 @@ -446,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); } @@ -461,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()); @@ -488,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) { @@ -496,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 @@ -526,12 +594,31 @@ public VendorHealthDto checkVendorHealth(String orderType) throws Exception { } @Override - public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request) throws Exception { + public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request, String jwtToken) + throws Exception { + boolean hasResult = request.getResultSummary() != null && !request.getResultSummary().isBlank(); + boolean hasReasonToClose = request.getReasonToClose() != null && !request.getReasonToClose().isBlank(); + if (hasResult == hasReasonToClose) { + throw new IllegalArgumentException("Exactly one of resultSummary or reasonToClose must be provided"); + } + + // Username (JWT subject), not the numeric user id — matches modifiedBy/createdBy elsewhere + // in this codebase being a display username, not an id. + String actingUserName = jwtUtil.extractUsername(jwtToken); + + if (hasReasonToClose) { + DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); + DiagnosticOrder closed = closeOrderManually(request.getBeneficiaryId(), orderType, + request.getReasonToClose(), actingUserName); + return toResultDto(closed); + } + DiagnosticOrder order = findLatestOrder(request.getBeneficiaryId(), request.getOrderType()); if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { throw new IllegalStateException("Cannot submit manual result: order is already COMPLETED (beneficiaryId=" + request.getBeneficiaryId() + ", orderType=" + request.getOrderType() + ")"); } + order.setManuallyEnteredBy(actingUserName); DiagnosticPollResult pollResult = new DiagnosticPollResult( DiagnosticOrderStatus.COMPLETED, null, request.getResultSummary(), null, null, null, null, null, null); return processResult(order, pollResult); diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java index b21c7095..2dbb183f 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -13,14 +13,16 @@ import org.springframework.stereotype.Service; import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; -// Every due order (status PENDING/IN_PROGRESS) is polled on every tick from the moment it's created — +// Every due order (status PENDING) is polled on every tick from the moment it's created — // there is no per-order initial delay or rolling give-up window anymore. A tick is skipped entirely // for an order-type family if diagnostic.provider.xray / diagnostic.provider.truenat has no active // vendor configured right now (checked live via DiagnosticProviderFactory, not any value stored on the -// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING/IN_PROGRESS (for -// order-type families that do have an active vendor) and marks it EXPIRED. +// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING (for +// order-type families that do have an active vendor) and marks it CLOSED. @Service public class DiagnosticPollSchedulerService { @@ -67,7 +69,7 @@ public void pollTrueNatOrders() { logger.info("Polled {} pending TrueNat diagnostic orders", candidates.size()); } - @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 0 18 * * *}") + @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 50 23 * * *}") public void expireOutstandingOrders() { List outstanding = diagnosticOrderRepo.findAllOutstandingOrders(); int expired = 0; @@ -79,7 +81,7 @@ public void expireOutstandingOrders() { expired++; } if (expired > 0) { - logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) EXPIRED", expired); + logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) CLOSED", expired); } } @@ -89,10 +91,13 @@ private boolean noActiveVendor(DiagnosticOrderType orderType) { } private void expire(DiagnosticOrder order) { - order.setStatus(DiagnosticOrderStatus.EXPIRED.name()); - order.setErrorMessage("Daily polling cutoff reached without a result"); + String reason = "Order validity expired for the day " + LocalDate.now(ZoneId.of("Asia/Kolkata")); + order.setStatus(DiagnosticOrderStatus.CLOSED.name()); + order.setReasonToClose(reason); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setProcessed("N"); diagnosticOrderRepo.save(order); + diagnosticOrderService.notifyProviderOrderClosed(order, reason); } private void pollSingle(DiagnosticOrder order) { @@ -105,6 +110,7 @@ private void pollSingle(DiagnosticOrder order) { order.setRetryCount(order.getRetryCount() + 1); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); order.setErrorMessage(e.getMessage()); + order.setProcessed("N"); diagnosticOrderRepo.save(order); } } From 4b6da82f0480a7b677d8e7adebf3a7d3e2ce5158 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 28 Sep 2026 19:25:28 +0530 Subject: [PATCH 18/33] 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 9583947c..b0238b0c 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 22725633323e4ed94d6129ebfd95c88137faba2b Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 29 Sep 2026 10:10:24 +0530 Subject: [PATCH 19/33] 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 b0238b0c..a1d4ebe5 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 f746af101e19d1edd6ebff1fe41adc0abf305e3d Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 29 Sep 2026 11:15:10 +0530 Subject: [PATCH 20/33] 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 8aeb045b..12ef9afc 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 2dbb183f..2259d934 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 d55f00c155093ea715e0ccef9696b14e861b1846 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 29 Sep 2026 15:50:41 +0530 Subject: [PATCH 21/33] 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 12ef9afc..19045367 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 3e3159ec4caea29145d5a032e1c65414101673b4 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Wed, 30 Sep 2026 17:44:50 +0530 Subject: [PATCH 22/33] 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 19045367..4d62aac5 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 272a97ceccea2eb271233f1b78aca6dd00e4c068 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Mon, 5 Oct 2026 13:59:18 +0530 Subject: [PATCH 23/33] 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 59295cbf..254313e9 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 645fa0d1..9a35f1a8 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 d53a5823..8ab1e390 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 00000000..ee1d197e --- /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 6682404a..4831f957 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 03f1622e..f3ec7b39 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 f389b5de..c2abe9a0 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 1d8583ae..e7a8c8ec 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 0d8e8796..7b5b3418 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 00000000..bfcb384d --- /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 ed2d92d5..b3e5558c 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 e61bc5c0..1df9a201 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 d5de312b..235093d4 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 e163959c..f74088a6 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 f03398c5..9a6e0487 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 ad6493afeee577843a655b02b7d53fb07c62ad1f Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Tue, 6 Oct 2026 18:33:54 +0530 Subject: [PATCH 24/33] 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 5fc0bc73..d7ba21d7 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 - - -