From d9ba042498da6fcba78d49da815ea2b390aa3e23 Mon Sep 17 00:00:00 2001 From: Mithun James <1007084+drtechie@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:23:08 +0530 Subject: [PATCH 1/3] fix(sync): build feed beneficiary pages in batches and send 100 per page The changes-feed snapshot capped beneficiaries at 25 per page because each one used to cost several identity lookups and a FHIR call. Records are now built with one query per table for the whole page (WorklistPagePrefetch, health IDs read locally); the remaining per-beneficiary mapping and address lookups in getBeneficiaryRecordsByRegIds are batched too, with the old per-row queries as the fallback. Raise the cap to 100 so a new tablet needs a quarter of the pages for its first download. Co-Authored-By: Claude Opus 5.5 --- .../service/impl/BeneficiaryServiceImpl.java | 19 +++++++++----- .../service/impl/StopTBSyncServiceImpl.java | 8 +++--- .../service/impl/WorklistPagePrefetch.java | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index bc1edc37..3fd8a525 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -266,12 +266,19 @@ private String getMappingsForAddressIDs(List addressLi // Used by the STOP-TB changes feed; a BenRegId with no mapping or address is left out. @Override public Map getBeneficiaryRecordsByRegIds(List benRegIds, String authorisation) { - List addresses = new ArrayList<>(); - for (Long benRegId : benRegIds) { - RMNCHMBeneficiarymapping m = beneficiaryRepo.getById(BigInteger.valueOf(benRegId)); - if (m == null || m.getBenAddressId() == null) continue; - RMNCHMBeneficiaryaddress a = beneficiaryRepo.getAddressById(m.getBenAddressId()); - if (a != null) addresses.add(a); + // Two queries for the whole page; the per-beneficiary lookups stay as the fallback. + List addresses; + try { + addresses = worklistPagePrefetch.addressesForRegIds(benRegIds); + } catch (Exception e) { + logger.warn("Beneficiary address prefetch failed, using per-beneficiary queries: " + e.getMessage()); + addresses = new ArrayList<>(); + for (Long benRegId : benRegIds) { + RMNCHMBeneficiarymapping m = beneficiaryRepo.getById(BigInteger.valueOf(benRegId)); + if (m == null || m.getBenAddressId() == null) continue; + RMNCHMBeneficiaryaddress a = beneficiaryRepo.getAddressById(m.getBenAddressId()); + if (a != null) addresses.add(a); + } } Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy h:mm:ss a").create(); Map records = new LinkedHashMap<>(); diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java index 4b144d60..f4242206 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java @@ -43,10 +43,12 @@ public class StopTBSyncServiceImpl implements StopTBSyncService { static final int DEFAULT_LIMIT = 200; static final int MAX_LIMIT = 500; /** - * Beneficiary records are the expensive ones (several identity lookups and a FHIR call each), so a - * page carries at most this many; the token resumes from the last one sent. + * Beneficiary records are the most expensive to build, so a page carries at most this many; the + * token resumes from the last one sent. They are now built with one query per table for the whole + * page (WorklistPagePrefetch, health IDs read locally), so the cap was raised from 25 to cut the + * number of pages a new tablet needs for its first download. */ - static final int MAX_BENEFICIARIES_PER_PAGE = 25; + static final int MAX_BENEFICIARIES_PER_PAGE = 100; /** Margin under the oldest open transaction (see safePoint); lines younger than this wait. */ static final int SAFE_LAG_SECONDS = 5; /** The hub keeps 30 days of log; a token older than this may point at cleaned-up lines. */ diff --git a/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java b/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java index b34acf9a..03f3e1dd 100644 --- a/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java +++ b/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java @@ -153,6 +153,32 @@ public Page load(List pageAddresses) { return page; } + /** + * The address row of each beneficiary, in the order of regIds, using one query for the mappings + * and one for the addresses instead of two per beneficiary. A beneficiary with no mapping or no + * address is left out, as the per-row lookup did. + */ + public List addressesForRegIds(List regIds) { + if (regIds.isEmpty()) return Collections.emptyList(); + Map addressIdByRegId = new HashMap<>(); + for (Object[] r : rows(identityEm, + "SELECT BenRegId, BenAddressId FROM db_identity.i_beneficiarymapping WHERE BenRegId IN (%s) ORDER BY BenMapId", + new LinkedHashSet<>(regIds))) { + if (r[1] != null) addressIdByRegId.putIfAbsent(toLong(r[0]), toBigInteger(r[1])); + } + Map addressById = new HashMap<>(); + putAll(addressById, entities(identityEm, RMNCHMBeneficiaryaddress.class, + "SELECT * FROM db_identity.i_beneficiaryaddress WHERE BenAddressID IN (%s)", + new LinkedHashSet<>(addressIdByRegId.values())), RMNCHMBeneficiaryaddress::getBenAddressID); + List out = new ArrayList<>(); + for (Long regId : regIds) { + BigInteger addressId = addressIdByRegId.get(regId); + RMNCHMBeneficiaryaddress a = addressId == null ? null : addressById.get(addressId); + if (a != null) out.add(a); + } + return out; + } + @SuppressWarnings("unchecked") private static List entities(EntityManager em, Class type, String sql, Collection ids) { if (ids.isEmpty()) return Collections.emptyList(); From e1acf2b7d4439a32b2501fc89ad4efa40adab53f Mon Sep 17 00:00:00 2001 From: Mithun James <1007084+drtechie@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:05:27 +0530 Subject: [PATCH 2/3] fix(sync): use the newest mapping when batching beneficiary addresses Co-Authored-By: Claude Opus 5.5 --- .../com/iemr/flw/service/impl/WorklistPagePrefetch.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java b/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java index 03f3e1dd..66e76fe1 100644 --- a/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java +++ b/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java @@ -155,14 +155,15 @@ public Page load(List pageAddresses) { /** * The address row of each beneficiary, in the order of regIds, using one query for the mappings - * and one for the addresses instead of two per beneficiary. A beneficiary with no mapping or no - * address is left out, as the per-row lookup did. + * and one for the addresses instead of two per beneficiary. If a beneficiary has several mappings, + * the newest one with an address is used. A beneficiary with no mapping or no address is left out, + * as the per-row lookup did. */ public List addressesForRegIds(List regIds) { if (regIds.isEmpty()) return Collections.emptyList(); Map addressIdByRegId = new HashMap<>(); for (Object[] r : rows(identityEm, - "SELECT BenRegId, BenAddressId FROM db_identity.i_beneficiarymapping WHERE BenRegId IN (%s) ORDER BY BenMapId", + "SELECT BenRegId, BenAddressId FROM db_identity.i_beneficiarymapping WHERE BenRegId IN (%s) ORDER BY BenMapId DESC", new LinkedHashSet<>(regIds))) { if (r[1] != null) addressIdByRegId.putIfAbsent(toLong(r[0]), toBigInteger(r[1])); } From 514ce59f29e760cc90246e973862c914954d45e4 Mon Sep 17 00:00:00 2001 From: Mithun James <1007084+drtechie@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:34:47 +0530 Subject: [PATCH 3/3] fix(stoptb): camp hub creates the X-ray / TrueNat orders a screening refers for (STOP-774) The tablet placed these orders with its own order/push right after saving the screening. When it could not reach the hub at that moment the order was marked FAILED on the tablet only and never retried, and the registrar has no access to the X-ray/TrueNat screens to retry it. - After a screening save commits, the hub creates XRAY_CHEST / MTB orders for today's referred screenings on a single background thread. - A sweep every 2 minutes (diagnostic.referral-sweep.tick-ms) covers referred screenings from today that still have no order. - Skips beneficiaries with an order of that type created today (any status, so a FAILED device push stays with the nurse's Retry) or an open/completed one. - Only on a camp hub (stoptb.van.id set); central never sends device orders. Co-Authored-By: Claude Opus 5.5 --- .../flw/repo/iemr/DiagnosticOrderRepo.java | 10 + .../iemr/flw/repo/iemr/TBScreeningRepo.java | 7 + .../impl/StopTBReferralOrderService.java | 230 ++++++++++++++++++ .../flw/service/impl/StopTBServiceImpl.java | 9 + .../impl/StopTBReferralOrderServiceTest.java | 151 ++++++++++++ 5 files changed, 407 insertions(+) create mode 100644 src/main/java/com/iemr/flw/service/impl/StopTBReferralOrderService.java create mode 100644 src/test/java/com/iemr/flw/service/impl/StopTBReferralOrderServiceTest.java 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 36ecc642..bba6e3c6 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -123,4 +123,14 @@ List findBeneficiaryIdsAwaitingManualEntry(@Param("orderType") String orde "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") List findLatestOrderStatusByBeneficiary(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + // True when the beneficiary already has an order of this type that a new referral must not + // duplicate: any row created since dayStart (whatever its status, so a FAILED push is left for + // the nurse's Retry instead of being re-sent every sweep), or an open/resolved order from an + // earlier day, which createAndPushOrder would return instead of creating a new one anyway. + @Query("SELECT COUNT(o) > 0 FROM DiagnosticOrder o WHERE o.beneficiaryId = :beneficiaryId " + + "AND o.orderType = :orderType AND o.deleted = false " + + "AND (o.createdDate >= :dayStart OR o.status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'MANUAL_ENTRY'))") + boolean hasOrderTodayOrOpen(@Param("beneficiaryId") Long beneficiaryId, @Param("orderType") String orderType, + @Param("dayStart") Timestamp dayStart); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBScreeningRepo.java b/src/main/java/com/iemr/flw/repo/iemr/TBScreeningRepo.java index cfb0665d..3f427973 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/TBScreeningRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/TBScreeningRepo.java @@ -50,4 +50,11 @@ public interface TBScreeningRepo extends JpaRepository { @Modifying @Query("UPDATE TBScreening t SET t.vanSerialNo = t.id WHERE t.id = :id") void updateVanSerialNo(@Param("id") Long id); + + // This van's screenings since dayStart that referred the beneficiary for a chest X-ray or a + // sputum test; read by the hub's referral-order sweep. + @Query("SELECT tbs FROM TBScreening tbs WHERE tbs.vanID = :vanID AND tbs.deleted = false " + + "AND tbs.visitDate >= :dayStart " + + "AND (tbs.referredForDigitalChestXray = true OR tbs.referredForSputumCollection = true)") + List findReferredSince(@Param("vanID") Integer vanID, @Param("dayStart") Timestamp dayStart); } diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBReferralOrderService.java b/src/main/java/com/iemr/flw/service/impl/StopTBReferralOrderService.java new file mode 100644 index 00000000..43b4c20a --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/StopTBReferralOrderService.java @@ -0,0 +1,230 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.identity.RMNCHMBeneficiarydetail; +import com.iemr.flw.domain.identity.RMNCHMBeneficiarymapping; +import com.iemr.flw.domain.iemr.TBScreening; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.repo.identity.BeneficiaryRepo; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.repo.iemr.TBScreeningRepo; +import com.iemr.flw.service.CampConfigService; +import com.iemr.flw.service.DiagnosticOrderService; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.math.BigInteger; +import java.sql.Timestamp; +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Creates the diagnostic orders a TB screening refers the beneficiary for (chest X-ray, sputum + * TrueNat) on the camp hub itself, from the screening record it receives. + * + * The tablet used to place these orders with its own order/push call right after saving the + * screening. When the tablet could not reach the hub at that moment the order was marked FAILED on + * the tablet only and never retried, and the registrar who fills the screening has no access to + * the X-ray/TrueNat screens to retry it. The screening itself always reaches the hub later through + * normal sync, so the hub creating the order from it removes the dependency on the tablet being + * connected at save time. + * + * Two triggers, both limited to screenings dated today (hub local date) on this camp's van: + * - right after a screening save commits (single background thread, so a slow or unreachable + * device never holds up screening sync); + * - a periodic sweep for referred screenings that still have no order, covering screenings saved + * before this existed and any attempt that died. + * + * Duplicates are prevented by DiagnosticOrderService's per-beneficiary lock and dedup, and by + * skipping beneficiaries that already have an order of that type today or an open one. + * Only runs where a camp van is configured, so the central server never sends orders to devices. + */ +@Service +public class StopTBReferralOrderService { + + private static final Logger logger = LoggerFactory.getLogger(StopTBReferralOrderService.class); + + static final String ORDER_EVENT = "STOP_TB_REFERRAL"; + + @Autowired + private DiagnosticOrderService diagnosticOrderService; + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private TBScreeningRepo tbScreeningRepo; + + @Autowired + private BeneficiaryRepo beneficiaryRepo; + + @Autowired + private CampConfigService campConfigService; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "stoptb-referral-orders"); + t.setDaemon(true); + return t; + }); + + // Set while a sweep's batch is queued or running, so a backlog (e.g. an unreachable device + // holding each push to its timeout) is not queued again on every tick. + private final AtomicBoolean sweepRunning = new AtomicBoolean(false); + + /** One saved screening's referral flags. */ + public record Referral(Long beneficiaryRegID, boolean xray, boolean sputum) { + boolean any() { + return xray || sputum; + } + } + + /** + * Queues order creation for the given referrals once the caller's transaction commits (or right + * away when there is none), so the orders are created against committed screening rows. + */ + public void createAfterCommit(List referrals) { + if (!campConfigService.isCampConfigured()) return; + List pending = referrals.stream().filter(Referral::any).toList(); + if (pending.isEmpty()) return; + Runnable submit = () -> executor.submit(() -> pending.forEach(this::createOrders)); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + submit.run(); + } + }); + } else { + submit.run(); + } + } + + // Runs on the shared scheduler thread, so it only reads and hands the work to the referral + // thread: an order push can wait on the device for its full timeout. + @Scheduled(fixedDelayString = "${diagnostic.referral-sweep.tick-ms:120000}", + initialDelayString = "${diagnostic.referral-sweep.initial-delay-ms:60000}") + public void sweep() { + if (!campConfigService.isCampConfigured()) return; + if (!sweepRunning.compareAndSet(false, true)) return; + try { + List referred = tbScreeningRepo.findReferredSince(campConfigService.getVanID(), startOfToday()); + List referrals = new ArrayList<>(); + for (TBScreening s : referred) { + referrals.add(new Referral(s.getBenRegID(), + Boolean.TRUE.equals(s.getReferredForDigitalChestXray()), + Boolean.TRUE.equals(s.getReferredForSputumCollection()))); + } + if (referrals.isEmpty()) { + sweepRunning.set(false); + return; + } + logger.debug("Referral sweep: {} referred screening(s) today", referrals.size()); + executor.submit(() -> { + try { + referrals.forEach(this::createOrders); + } finally { + sweepRunning.set(false); + } + }); + } catch (RuntimeException e) { + sweepRunning.set(false); + logger.error("Referral sweep failed: {}", e.getMessage(), e); + } + } + + void createOrders(Referral referral) { + try { + if (referral.beneficiaryRegID() == null) return; + if (referral.xray()) createIfMissing(referral.beneficiaryRegID(), DiagnosticOrderType.XRAY_CHEST); + if (referral.sputum()) createIfMissing(referral.beneficiaryRegID(), DiagnosticOrderType.MTB); + } catch (Exception e) { + logger.error("Referral orders failed for benRegID={}: {}", referral.beneficiaryRegID(), e.getMessage(), e); + } + } + + private void createIfMissing(Long beneficiaryRegID, DiagnosticOrderType orderType) { + if (!screenedToday(beneficiaryRegID)) return; + BigInteger benId = beneficiaryRepo.getBenIdFromRegID(beneficiaryRegID); + if (benId == null) { + logger.warn("Referral order skipped: no beneficiaryID for benRegID={}", beneficiaryRegID); + return; + } + Long beneficiaryId = benId.longValue(); + if (diagnosticOrderRepo.hasOrderTodayOrOpen(beneficiaryId, orderType.name(), startOfToday())) return; + + DiagnosticOrderRequestDto.PatientDto patient = patientFor(beneficiaryRegID); + if (patient == null) { + logger.warn("Referral order skipped: no beneficiary details for benRegID={}", beneficiaryRegID); + return; + } + DiagnosticOrderRequestDto request = new DiagnosticOrderRequestDto(); + request.setBeneficiaryId(beneficiaryId); + request.setOrderType(orderType.name()); + request.setOrderEvent(ORDER_EVENT); + request.setPatient(patient); + try { + diagnosticOrderService.createAndPushOrderAsSystem(request); + logger.info("Referral order created for beneficiaryId={}, orderType={}", beneficiaryId, orderType); + } catch (Exception e) { + logger.error("Referral order failed for beneficiaryId={}, orderType={}: {}", + beneficiaryId, orderType, e.getMessage()); + } + } + + // The latest screening must be today's: an order is for the patient who is at the camp now, + // not for one screened on an earlier day whose record only reached the hub today. + private boolean screenedToday(Long beneficiaryRegID) { + List latest = tbScreeningRepo.findLatestByBenRegID(beneficiaryRegID, + org.springframework.data.domain.PageRequest.of(0, 1)); + return !latest.isEmpty() && latest.get(0).getVisitDate() != null + && !latest.get(0).getVisitDate().before(startOfToday()); + } + + DiagnosticOrderRequestDto.PatientDto patientFor(Long beneficiaryRegID) { + List mappings = + beneficiaryRepo.findByBenRegIdFromMapping(BigInteger.valueOf(beneficiaryRegID)); + if (mappings.isEmpty() || mappings.get(0).getBenDetailsId() == null) return null; + RMNCHMBeneficiarydetail detail = beneficiaryRepo.getDetailsById(mappings.get(0).getBenDetailsId()); + if (detail == null) return null; + + DiagnosticOrderRequestDto.PatientDto patient = new DiagnosticOrderRequestDto.PatientDto(); + patient.setFirstName(detail.getFirstName() != null ? detail.getFirstName().trim() : ""); + patient.setLastName(detail.getLastName() != null ? detail.getLastName().trim() : ""); + patient.setDateOfBirth(detail.getDob() != null ? new SimpleDateFormat("yyyy-MM-dd").format(detail.getDob()) : null); + patient.setSex(sexOf(detail.getGender(), detail.getGenderId())); + return patient; + } + + // Same three values the tablet sent (Male / Female / Other). + static String sexOf(String gender, Integer genderId) { + if (gender != null) { + if (gender.trim().equalsIgnoreCase("Male")) return "Male"; + if (gender.trim().equalsIgnoreCase("Female")) return "Female"; + } + if (genderId != null) { + if (genderId == 1) return "Male"; + if (genderId == 2) return "Female"; + } + return "Other"; + } + + static Timestamp startOfToday() { + return Timestamp.valueOf(LocalDate.now().atStartOfDay()); + } + + @PreDestroy + void shutdown() { + executor.shutdown(); + } +} 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 3c00b197..2100313b 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -76,6 +76,9 @@ public class StopTBServiceImpl implements StopTBService { @Autowired private TBStopVisitService tbStopVisitService; + @Autowired + private StopTBReferralOrderService referralOrderService; + @Autowired private BenAnthropometryRepo benAnthropometryRepo; @@ -225,6 +228,7 @@ private Map examToMap(StopTBGeneralExamination e) { @Transactional public List> saveNurseTBScreening(List> dataList) throws Exception { List> results = new ArrayList<>(); + List referrals = new ArrayList<>(); Integer vanID = campConfigService.getVanID(); Integer parkingPlaceID = campConfigService.getParkingPlaceID(); for (Map data : dataList) { @@ -300,12 +304,17 @@ public List> saveNurseTBScreening(List> tbScreeningRepo.save(screening); if (isNew) tbScreeningRepo.updateVanSerialNo(screening.getId()); + referrals.add(new StopTBReferralOrderService.Referral(beneficiaryRegID, + Boolean.TRUE.equals(screening.getReferredForDigitalChestXray()), + Boolean.TRUE.equals(screening.getReferredForSputumCollection()))); Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); result.put("visitCode", visit.getVisitCode()); results.add(result); } + // The hub, not the tablet, places the X-ray / TrueNat orders the screening refers for. + referralOrderService.createAfterCommit(referrals); return results; } diff --git a/src/test/java/com/iemr/flw/service/impl/StopTBReferralOrderServiceTest.java b/src/test/java/com/iemr/flw/service/impl/StopTBReferralOrderServiceTest.java new file mode 100644 index 00000000..0422bc95 --- /dev/null +++ b/src/test/java/com/iemr/flw/service/impl/StopTBReferralOrderServiceTest.java @@ -0,0 +1,151 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.identity.RMNCHMBeneficiarydetail; +import com.iemr.flw.domain.identity.RMNCHMBeneficiarymapping; +import com.iemr.flw.domain.iemr.TBScreening; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.repo.identity.BeneficiaryRepo; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.repo.iemr.TBScreeningRepo; +import com.iemr.flw.service.CampConfigService; +import com.iemr.flw.service.DiagnosticOrderService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Pageable; + +import java.math.BigInteger; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class StopTBReferralOrderServiceTest { + + private static final long REG_ID = 359055L; + private static final long BEN_ID = 601413456541L; + + @Mock private DiagnosticOrderService diagnosticOrderService; + @Mock private DiagnosticOrderRepo diagnosticOrderRepo; + @Mock private TBScreeningRepo tbScreeningRepo; + @Mock private BeneficiaryRepo beneficiaryRepo; + @Mock private CampConfigService campConfigService; + + @InjectMocks private StopTBReferralOrderService service; + + private void givenBeneficiary(LocalDateTime screenedAt) { + TBScreening screening = new TBScreening(); + screening.setBenRegID(REG_ID); + screening.setVisitDate(Timestamp.valueOf(screenedAt)); + when(tbScreeningRepo.findLatestByBenRegID(eq(REG_ID), any(Pageable.class))).thenReturn(List.of(screening)); + when(beneficiaryRepo.getBenIdFromRegID(REG_ID)).thenReturn(BigInteger.valueOf(BEN_ID)); + + RMNCHMBeneficiarymapping mapping = new RMNCHMBeneficiarymapping(); + mapping.setBenDetailsId(BigInteger.TEN); + when(beneficiaryRepo.findByBenRegIdFromMapping(BigInteger.valueOf(REG_ID))).thenReturn(List.of(mapping)); + RMNCHMBeneficiarydetail detail = new RMNCHMBeneficiarydetail(); + detail.setFirstName(" Arun "); + detail.setLastName(null); + detail.setDob(Timestamp.valueOf(LocalDateTime.of(2019, 3, 4, 0, 0))); + detail.setGender("Male"); + when(beneficiaryRepo.getDetailsById(BigInteger.TEN)).thenReturn(detail); + } + + @Test + void xrayReferralTodayWithNoOrderCreatesXrayOrder() throws Exception { + givenBeneficiary(LocalDateTime.now()); + when(diagnosticOrderRepo.hasOrderTodayOrOpen(anyLong(), anyString(), any())).thenReturn(false); + + service.createOrders(new StopTBReferralOrderService.Referral(REG_ID, true, false)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DiagnosticOrderRequestDto.class); + verify(diagnosticOrderService, times(1)).createAndPushOrderAsSystem(captor.capture()); + DiagnosticOrderRequestDto request = captor.getValue(); + assertEquals(BEN_ID, request.getBeneficiaryId()); + assertEquals("XRAY_CHEST", request.getOrderType()); + assertEquals("STOP_TB_REFERRAL", request.getOrderEvent()); + assertEquals("Arun", request.getPatient().getFirstName()); + assertEquals("", request.getPatient().getLastName()); + assertEquals("2019-03-04", request.getPatient().getDateOfBirth()); + assertEquals("Male", request.getPatient().getSex()); + } + + @Test + void sputumReferralCreatesMtbOrder() throws Exception { + givenBeneficiary(LocalDateTime.now()); + when(diagnosticOrderRepo.hasOrderTodayOrOpen(anyLong(), anyString(), any())).thenReturn(false); + + service.createOrders(new StopTBReferralOrderService.Referral(REG_ID, false, true)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DiagnosticOrderRequestDto.class); + verify(diagnosticOrderService).createAndPushOrderAsSystem(captor.capture()); + assertEquals("MTB", captor.getValue().getOrderType()); + } + + @Test + void existingOrderTodayOrOpenIsNotDuplicated() throws Exception { + givenBeneficiary(LocalDateTime.now()); + when(diagnosticOrderRepo.hasOrderTodayOrOpen(eq(BEN_ID), eq("XRAY_CHEST"), any())).thenReturn(true); + + service.createOrders(new StopTBReferralOrderService.Referral(REG_ID, true, false)); + + verify(diagnosticOrderService, never()).createAndPushOrderAsSystem(any()); + } + + @Test + void screeningFromAnEarlierDayGetsNoOrder() throws Exception { + givenBeneficiary(LocalDate.now().minusDays(1).atTime(17, 14)); + when(diagnosticOrderRepo.hasOrderTodayOrOpen(anyLong(), anyString(), any())).thenReturn(false); + + service.createOrders(new StopTBReferralOrderService.Referral(REG_ID, true, true)); + + verify(diagnosticOrderService, never()).createAndPushOrderAsSystem(any()); + } + + @Test + void vendorFailureIsContainedPerOrderType() throws Exception { + givenBeneficiary(LocalDateTime.now()); + when(diagnosticOrderRepo.hasOrderTodayOrOpen(anyLong(), anyString(), any())).thenReturn(false); + when(diagnosticOrderService.createAndPushOrderAsSystem(any())).thenThrow(new RuntimeException("device down")); + + service.createOrders(new StopTBReferralOrderService.Referral(REG_ID, true, true)); + + verify(diagnosticOrderService, times(2)).createAndPushOrderAsSystem(any()); + } + + @Test + void notACampHubDoesNothing() { + when(campConfigService.isCampConfigured()).thenReturn(false); + + service.createAfterCommit(List.of(new StopTBReferralOrderService.Referral(REG_ID, true, true))); + service.sweep(); + + verifyNoInteractions(tbScreeningRepo, diagnosticOrderRepo, diagnosticOrderService, beneficiaryRepo); + } + + @Test + void sexMapping() { + assertEquals("Male", StopTBReferralOrderService.sexOf("male", null)); + assertEquals("Female", StopTBReferralOrderService.sexOf(null, 2)); + assertEquals("Other", StopTBReferralOrderService.sexOf("Transgender", 3)); + assertEquals("Other", StopTBReferralOrderService.sexOf(null, null)); + } +}