From 5b943613436833db0e07eb1aeb62fec2843d6083 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 25 Sep 2026 09:52:47 +0530 Subject: [PATCH 01/14] chore: bump pom version to 3.9.1 Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1d3e8872..bfec2491 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.8.4 + 3.9.1 war FLW-API From dee56117b74c1371db4496c06b280997316f8e8f Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 6 Oct 2026 10:00:16 +0530 Subject: [PATCH 02/14] fix(diagnostic): stop the poll scheduler from nulling vanSerialNo pollOnce saved a stale copy of the order loaded before createAndPushOrder's updateVanSerialNo() ran, writing vanSerialNo back to NULL. MMU sync keys tb_diagnostic_order by vanSerialNo, so those rows could never sync and the Stop TB sync group stayed partial. - refill vanSerialNo after the scheduler's save in pollOnce - mark vanSerialNo updatable=false on the diagnostic entities so entity saves can no longer overwrite it (updateVanSerialNo still sets it) Co-Authored-By: Claude Opus 5.5 --- .../java/com/iemr/flw/domain/iemr/DiagnosticDocument.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java | 2 +- .../com/iemr/flw/domain/iemr/DiagnosticProviderToken.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java | 2 +- .../com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java | 4 +++- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java index d4e78747..5afd367a 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java @@ -84,6 +84,6 @@ public class DiagnosticDocument { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java index 197999fe..aa89012e 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -101,6 +101,6 @@ public class DiagnosticOrder { @Column(name = "processed") private String processed = "N"; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java index 8ef23306..da8e23dd 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java @@ -46,6 +46,6 @@ public class DiagnosticProviderToken { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java index 6cb9fba6..55bce475 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java @@ -70,6 +70,6 @@ public class DiagnosticResult { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java b/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java index f9abde57..e81b553d 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java +++ b/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java @@ -135,6 +135,6 @@ public class StopTBDiagnostics { @Column(name = "processed") private String processed = "N"; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 3e5ca60d..adf64d85 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -338,7 +338,9 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { order.setStatus(DiagnosticOrderStatus.FAILED.name()); order.setErrorMessage("No push response recorded for this order — cannot poll"); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); - diagnosticOrderRepo.save(order); + order = diagnosticOrderRepo.save(order); + // The scheduler's copy may predate createAndPushOrder's updateVanSerialNo(); fill it if still missing. + if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); return null; } DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); From 290265f87580fb1eea39e265cccf7912b6bc74b4 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 6 Oct 2026 10:35:02 +0530 Subject: [PATCH 03/14] fix(diagnostic): stamp vanSerialNo right after the order is first saved Fill vanSerialNo before the (possibly slow) EMR Lite push instead of only after it, so a crash or restart mid-push can't leave the order without its sync key. Same value (= id); no change to status, push or response. Co-Authored-By: Claude Opus 5.5 --- .../iemr/flw/service/impl/DiagnosticOrderServiceImpl.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index adf64d85..d8dfc6ab 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -136,6 +136,11 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr } throw dive; } + // Stamp the sync key before the (possibly slow) vendor push, so a crash/restart mid-push can't leave it NULL. + if (order.getVanSerialNo() == null) { + diagnosticOrderRepo.updateVanSerialNo(order.getId()); + order.setVanSerialNo(order.getId()); + } if (providerCode == null || providerCode.isBlank()) { logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry", From 04b46c7d52714575b3e936cce4ef82ad97ec3285 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 09:15:25 +0530 Subject: [PATCH 04/14] 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 f634cd022b232796c49567dc1157cfe6e3a72be7 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 09:27:59 +0530 Subject: [PATCH 05/14] 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 ef5697ba7e478e3e5e0b6210c41c578bda53f21f Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 13:51:08 +0530 Subject: [PATCH 06/14] 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 db518a2bc094418a53d1c4094e71abf4ab526a0a Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 14:17:31 +0530 Subject: [PATCH 07/14] 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 0869e7ee90fe43123b58f9a2be7ee92f240358c2 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 14:49:35 +0530 Subject: [PATCH 08/14] 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 5a0e2d4756ca834781d3d04fd81fac2fb8973ce4 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 15:24:44 +0530 Subject: [PATCH 09/14] 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 234c9367ed82c722f81b6ac46ad5ef2c5cf69272 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Thu, 8 Oct 2026 17:23:46 +0530 Subject: [PATCH 10/14] 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 b23c8e04989706dcc6cb4165bc657c23f25291e1 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Fri, 9 Oct 2026 13:29:20 +0530 Subject: [PATCH 11/14] updated version to 3.9.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index bfec2491..1f654e23 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.9.1 + 3.9.2 war FLW-API From f7c8f0305930e6535f0ed915037e93c9d816b5f3 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Mon, 12 Oct 2026 15:07:24 +0530 Subject: [PATCH 12/14] fix(stoptb): fill missing service line on the day's visit TB suspected / confirmed / diagnostic saves create the visit with ProviderServiceMapID null. When one of them syncs before TB screening or OPD, the visit keeps null and getAll (filters by psm) skips it. A later save that has a service line now fills it in. Co-Authored-By: Claude Opus 5.5 --- .../flw/service/impl/TBStopVisitServiceImpl.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java index 7297adfc..d3f3e485 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java @@ -25,7 +25,17 @@ public BenVisitDetail getOrCreateVisitForToday(Long beneficiaryRegID, Integer pr Timestamp dayEnd = Timestamp.valueOf(today.plusDays(1).atStartOfDay()); BenVisitDetail existing = benVisitDetailsRepo.findStopTBVisitForToday(beneficiaryRegID, dayStart, dayEnd); - if (existing != null) return existing; + if (existing != null) { + // TB suspected / confirmed / diagnostic saves create the visit without a service line; whichever + // form syncs first decides. Fill it from the first later save that has one (screening, OPD...). + if (existing.getProviderServiceMapID() == null && providerServiceMapID != null) { + existing.setProviderServiceMapID(providerServiceMapID); + existing.setModifiedBy(createdBy); + existing.setProcessed("N"); + existing = benVisitDetailsRepo.saveAndFlush(existing); + } + return existing; + } Integer visitCount = benVisitDetailsRepo.countStopTBVisits(beneficiaryRegID); short visitNo = (short) ((visitCount != null ? visitCount : 0) + 1); From 295d55303e8f4cb48389520ee9b4648ec24de1c3 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 16 Oct 2026 07:33:20 +0530 Subject: [PATCH 13/14] fix(stoptb): MMU dose-based quantity; no batch re-upload after dispense - Quantity for tablets uses the dose like MMU (Half Tab 0.5, One Tab 1, One & Half Tab 1.5, Two Tabs 2 per intake), rounded up. No dose sent = one tablet, so today's results are unchanged. - Dispense no longer marks the stock batch Processed='N'. Central gets the stock-out rows (standard MMU); re-sending a batch could insert a duplicate batch on central. Co-Authored-By: Claude Opus 5.5 --- .../service/StopTBPrescriptionService.java | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java index c84b6074..89f31d29 100644 --- a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -285,10 +285,11 @@ public Map prescribeAndDispense(Map prescription .setParameter("pp", parkingPlaceID) .executeUpdate(); stampVanSerialNo("t_itemstockexit", "ItemStockExitID", lastInsertId()); - // 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") + // Same update as Inventory-API ItemStockEntryRepo.updateStock. The batch is not marked for + // upload again (standard MMU): central gets the stock-out rows; re-sending a batch can insert a + // duplicate batch on central. + 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) @@ -474,31 +475,45 @@ private long insertPrescription(Long beneficiaryRegID, BenVisitDetail visit, Str } /** - * 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). + * Units to give when the app sends no quantity – MMU's rule (CommonNurseServiceImpl.calculateQtyPrescribed). + * Tablets/capsules: tablets per intake x intakes per day x days, rounded up. Tablets per intake come from the + * dose (Half Tab 0.5, One Tab 1, One & Half Tab 1.5, Two Tabs 2); no dose sent = one tablet (capsule always 1). + * Single/Stat dose = one intake. SOS = up to one intake a day (one intake when no duration). Once in a week = + * one intake per 7 days. Other forms (syrup, cream, injection, drops...): 1 bottle/tube/vial. Null when a + * tablet/capsule quantity cannot be worked out (unknown frequency or missing 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 1; // 1 = Tablet, 2 = Capsule + double perIntake = formID == 2 ? 1 : tabletsPerIntake(toStr(drug.get("dose"))); String frequency = Optional.ofNullable(toStr(drug.get("frequency"))).orElse("").toUpperCase(); - if (frequency.contains("SINGLE DOSE") || frequency.contains("STAT")) return 1; + if (frequency.contains("SINGLE DOSE") || frequency.contains("STAT")) return (int) Math.ceil(perIntake); Integer duration = toInt(drug.get("duration")); boolean sos = frequency.contains("SOS"); - if (duration == null || duration <= 0) return sos ? 1 : null; + if (duration == null || duration <= 0) return sos ? (int) Math.ceil(perIntake) : 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 + if (frequency.contains("WEEK")) return (int) Math.ceil(perIntake * ((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") || sos) perDay = 1; // SOS: up to 1 a day else return null; // unknown frequency - return perDay * days; + return (int) Math.ceil(perIntake * perDay * days); + } + + // MMU dose names (m_drugdose); anything else or no dose = one tablet + private static double tabletsPerIntake(String dose) { + if (dose == null) return 1; + String d = dose.trim().toLowerCase(); + if (d.startsWith("half")) return 0.5; + if (d.startsWith("one & half") || d.startsWith("one and half") || d.startsWith("1.5")) return 1.5; + if (d.startsWith("two")) return 2; + return 1; } // ItemID, ItemName, strength+UOM, form, route, isEDL, IssueType, ItemFormID — only if the drug is mapped to From eeb2a1d904eb459449a725400be90ad1e55d12ea Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 20 Oct 2026 10:21:50 +0530 Subject: [PATCH 14/14] fix(stoptb): send the batch's new stock to central after an OPD dispense The dispensed batch is marked Processed='N' again so the next upload updates central's copy (matched by VanSerialNo + store, now kept by central MMU-API). Co-Authored-By: Claude Opus 5.5 --- .../com/iemr/flw/service/StopTBPrescriptionService.java | 9 ++++----- 1 file changed, 4 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 89f31d29..b846bc02 100644 --- a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -285,11 +285,10 @@ public Map prescribeAndDispense(Map prescription .setParameter("pp", parkingPlaceID) .executeUpdate(); stampVanSerialNo("t_itemstockexit", "ItemStockExitID", lastInsertId()); - // Same update as Inventory-API ItemStockEntryRepo.updateStock. The batch is not marked for - // upload again (standard MMU): central gets the stock-out rows; re-sending a batch can insert a - // duplicate batch on central. - em.createNativeQuery("UPDATE db_iemr.t_itemstockentry SET QuantityInHand = QuantityInHand - :qty " - + "WHERE VanSerialNo = :batch AND FacilityID = :facilityID") + // Same update as Inventory-API ItemStockEntryRepo.updateStock. Processed='N' sends the batch's + // new stock to central on the next upload; central updates its copy by VanSerialNo + store. + 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)