From 647c69ed2b52cc812bbd634f675fc4fa36934131 Mon Sep 17 00:00:00 2001 From: Saurav Mishra <80103738+SauravBizbRolly@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:31:16 +0530 Subject: [PATCH 1/6] fix code merge issue (cherry picked from commit 3379e00c6b10429d86e75f76927a6c5a7a8f552f) --- .../iemr/flw/dto/iemr/UserServiceRoleDTO.java | 8 + .../flw/repo/iemr/UserServiceRoleRepo.java | 6 + .../flw/service/TBConfirmedCaseService.java | 10 + .../impl/TBConfirmedCaseServiceImpl.java | 535 +++++++++++++++++- 4 files changed, 537 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/iemr/flw/dto/iemr/UserServiceRoleDTO.java b/src/main/java/com/iemr/flw/dto/iemr/UserServiceRoleDTO.java index ead3ddc06..c336e1c98 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/UserServiceRoleDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/UserServiceRoleDTO.java @@ -27,6 +27,14 @@ public class UserServiceRoleDTO { private String villageName; private Map facilityData; + // Stop TB / Nikshay location scope — populated only when serviceName = "Stop TB". + // Set via setters after construction (additive, not part of the base constructor + // so every other service line's DTO construction stays exactly as it is today). + private String tuId; + private String tuName; + private String healthFacilityId; + private String healthFacilityName; + public UserServiceRoleDTO(Integer userId, String name, String userName, Integer stateId, String stateName, Integer workingDistrictId, String workingDistrictName, Short serviceProviderId, Integer roleId, String roleName, Integer providerServiceMapId, Integer blockId, String blockName, String villageId, String villageName) { this.userId = userId; this.name = name; diff --git a/src/main/java/com/iemr/flw/repo/iemr/UserServiceRoleRepo.java b/src/main/java/com/iemr/flw/repo/iemr/UserServiceRoleRepo.java index d2cdc2f46..95733b8a6 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/UserServiceRoleRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/UserServiceRoleRepo.java @@ -79,4 +79,10 @@ List getNikshayMappingRows(@Param("userId") Integer userId, @Query(value = "SELECT DistrictName FROM m_nikshay_district WHERE NikshayDistrictID = :id", nativeQuery = true) String findNikshayDistrictName(@Param("id") Integer id); + @Query("SELECT new com.iemr.flw.dto.iemr.UserServiceRoleDTO(u.userId, u.name, u.userName, u.stateId, u.stateName, u.workingDistrictId," + + "u.workingDistrictName, u.serviceProviderId, u.roleId, u.roleName, u.providerServiceMapId, u.blockid, u.blockname, u.villageid, " + + "u.villagename) FROM UserServiceRole u WHERE u.userId IN :userIds " + + "AND u.userServciceRoleDeleted = false AND u.userDeleted = false") + List getUserRolesByIds(@Param("userIds") List userIds); + } diff --git a/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java b/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java index b1d4165eb..67ca9e1a6 100644 --- a/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java +++ b/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java @@ -1,6 +1,8 @@ package com.iemr.flw.service; import com.iemr.flw.domain.iemr.TBConfirmedCaseDTO; +import com.iemr.flw.dto.iemr.TbReferralFollowUpDTO; +import com.iemr.flw.dto.iemr.TbTptFollowUpDTO; import org.springframework.stereotype.Service; import java.util.List; @@ -18,4 +20,12 @@ public interface TBConfirmedCaseService { // One record per row, in the same JSON shape getAll returns (STOP-TB changes feed), keyed by id. java.util.Map getRecordsByIds(java.util.Collection ids); + + String saveReferralFollowUp(TbReferralFollowUpDTO requestDTO, String token); + + String getReferralFollowUp(String token); + + String saveTptFollowUp(TbTptFollowUpDTO requestDTO, String token); + + String getTptFollowUp(String token); } diff --git a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java index 2db58ede3..133f46f37 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java @@ -1,15 +1,15 @@ package com.iemr.flw.service.impl; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.iemr.flw.domain.iemr.DynamicForm; -import com.iemr.flw.domain.iemr.TBConfirmedCaseDTO; -import com.iemr.flw.domain.iemr.TBConfirmedCase; -import com.iemr.flw.domain.iemr.BenVisitDetail; +import com.iemr.flw.domain.iemr.*; +import com.iemr.flw.dto.iemr.TbReferralFollowUpDTO; +import com.iemr.flw.dto.iemr.TbReferralFollowUpListDTO; +import com.iemr.flw.dto.iemr.TbTptFollowUpDTO; +import com.iemr.flw.dto.iemr.TbTptFollowUpListDTO; import com.iemr.flw.repo.identity.BeneficiaryRepo; -import com.iemr.flw.repo.iemr.DynamicFormRepo; -import com.iemr.flw.repo.iemr.FormResponseRepo; -import com.iemr.flw.repo.iemr.TBConfirmedTreatmentRepository; +import com.iemr.flw.repo.iemr.*; import com.iemr.flw.seeder.TbCounsellingV2FormSeeder; import com.iemr.flw.service.IncentiveLogicService; import com.iemr.flw.service.CampConfigService; @@ -25,12 +25,9 @@ import java.sql.Timestamp; import java.time.LocalDate; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; import java.util.stream.Collectors; @Service @@ -57,6 +54,15 @@ public class TBConfirmedCaseServiceImpl implements TBConfirmedCaseService { @Autowired private BeneficiaryRepo beneficiaryRepo; + @Autowired + private TbReferralFollowUpRepo tbReferralFollowUpRepo; + + @Autowired + private IncentiveLogicImpl incentiveLogic; + + @Autowired + private TbTptFollowUpRepo tbTptFollowUpRepo; + public TBConfirmedCaseServiceImpl(TBConfirmedTreatmentRepository repository, FormResponseRepo formResponseRepo, DynamicFormRepo dynamicFormRepo) { @@ -141,21 +147,37 @@ private void checkIncentive(TBConfirmedCase entity){ || "Longer Regimen (18–24 Months)".equalsIgnoreCase(regimen); if (Boolean.TRUE.equals(entity.getTreatmentCompleted()) - && entity.getExpectedTreatmentCompletionDate() != null) { + && entity.getFollowUpDate() != null) { if (isDrTb) { - incentiveLogicService.incentiveForTbFollowUpIsDrTb( - entity.getBenId(), - Timestamp.valueOf(entity.getExpectedTreatmentCompletionDate().atStartOfDay()), - Timestamp.valueOf(entity.getExpectedTreatmentCompletionDate().atStartOfDay()), - entity.getUserId() - ); + if("Shorter Regimen (9–12 Months)".equalsIgnoreCase(regimen)){ + if ("cured".equalsIgnoreCase(entity.getTreatmentOutcome())) { + incentiveLogicService.incentiveForTbFollowUpIsDrTb( + entity.getBenId(), + Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), + Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), + entity.getUserId() + ); + } + + }else if("Longer Regimen (18–24 Months)".equalsIgnoreCase(regimen)){ + if ("cured".equalsIgnoreCase(entity.getTreatmentOutcome())) { + + incentiveLogicService.incentiveForTbFollowUpIsDrTb( + entity.getBenId(), + Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), + Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), + entity.getUserId() + ); + } + } + } else { incentiveLogicService.incentiveForTbFollowUp( entity.getBenId(), - Timestamp.valueOf(entity.getExpectedTreatmentCompletionDate().atStartOfDay()), - Timestamp.valueOf(entity.getExpectedTreatmentCompletionDate().atStartOfDay()), + Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), + Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), entity.getUserId() ); @@ -164,6 +186,24 @@ private void checkIncentive(TBConfirmedCase entity){ } } + public boolean isEligibleForIncentive(LocalDate treatmentStartDate, LocalDate endDate,Integer minMonth) { + + if (treatmentStartDate == null || endDate == null) { + return false; + } + + LocalDate start = treatmentStartDate.atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toLocalDate(); + + LocalDate end = endDate.atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toLocalDate(); + + return end.isAfter(start.plusMonths(minMonth)) + || end.isEqual(start.plusMonths(minMonth)); + } + @Override public String getByBenId(Long benId, String authorisation) throws Exception { OutputResponse response = new OutputResponse(); @@ -215,6 +255,429 @@ public Map getRecordsByIds(java.util.Collection ids) { return out; } + @Override + public String saveReferralFollowUp(TbReferralFollowUpDTO requestDTO, String token) { + OutputResponse response = new OutputResponse(); + + try { + + TbReferralFollowUp tbReferralFollowUp = new TbReferralFollowUp(); + + if(!token.isEmpty() && token!=null && requestDTO!=null){ + String userName = jwtUtil.extractUsername(token); + Integer userId = jwtUtil.extractUserId(token); + tbReferralFollowUp.setBenId(requestDTO.getBenId()); + tbReferralFollowUp.setHouseHoldId(requestDTO.getHouseHoldId()); + tbReferralFollowUp.setSyncedBy(userName); + tbReferralFollowUp.setUpdatedBy(userName); + tbReferralFollowUp.setCreatedBy(userName); + tbReferralFollowUp.setUserId(userId); + if(requestDTO.getFields().getReferredOnDate()!=null && !requestDTO.getFields().getFollowUpDate().isEmpty()){ + Timestamp referOnDate = Timestamp.valueOf(requestDTO.getFields().getReferredOnDate()); + tbReferralFollowUp.setFollowUpDate(referOnDate); + + } + + if(requestDTO.getFields().getFollowUpDate()!=null && !requestDTO.getFields().getFollowUpDate().isEmpty()){ + Timestamp followUpDate = Timestamp.valueOf(requestDTO.getFields().getFollowUpDate()); + tbReferralFollowUp.setFollowUpDate(followUpDate); + + } + tbReferralFollowUp.setFollowUpStatus(requestDTO.getFields().getFollowUpStatus()); + } + + tbReferralFollowUpRepo.save(tbReferralFollowUp); + response.setResponse("TB Referral follow up saved successfully"); + + + + }catch (Exception e){ + response.setError(500,e.getMessage()); + return response.toString(); + } + + + return response.toString(); + } + + @Override + public String getReferralFollowUp(String token) { + OutputResponse response = new OutputResponse(); + + try { + if (token == null || token.trim().isEmpty()) { + response.setError(400, "Token is required"); + return response.toString(); + } + + Integer userId = jwtUtil.extractUserId(token); + + List referrals = + tbReferralFollowUpRepo.findByUserId(userId); + + List referralDTOs = new ArrayList<>(); + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + for (TbReferralFollowUp referral : referrals) { + + TbReferralFollowUpDTO dto = new TbReferralFollowUpDTO(); + dto.setBenId(referral.getBenId()); + dto.setUserId(referral.getUserId()); + dto.setHouseHoldId(referral.getHouseHoldId()); + + // Initialize nested DTO before setting fields + TbReferralFollowUpListDTO fields = + new TbReferralFollowUpListDTO(); + + if (referral.getReferredOnDate() != null) { + fields.setReferredOnDate( + referral.getReferredOnDate() + .toLocalDateTime() + .format(formatter) + ); + } + + if (referral.getFollowUpDate() != null) { + fields.setFollowUpDate( + referral.getFollowUpDate() + .toLocalDateTime() + .format(formatter) + ); + } + + fields.setFollowUpStatus(referral.getFollowUpStatus()); + + dto.setFields(fields); + referralDTOs.add(dto); + } + + ObjectMapper objectMapper = new ObjectMapper(); + + response.setResponse( + objectMapper.writeValueAsString(referralDTOs) + ); + + } catch (Exception e) { + // Replace with your project's logger if available + e.printStackTrace(); + response.setError(500, "Failed to fetch referral follow-up: " + e.getMessage()); + } + + return response.toString(); + } + + @Override + public String saveTptFollowUp(TbTptFollowUpDTO requestDTO, String token) { + + OutputResponse response = new OutputResponse(); + + try { + + TbTptFollowUp tbTptFollowUp = new TbTptFollowUp(); + + if (token != null && !token.isEmpty() && requestDTO != null) { + + String userName = jwtUtil.extractUsername(token); + Integer userId = jwtUtil.extractUserId(token); + + tbTptFollowUp.setBenId(requestDTO.getBenId()); + tbTptFollowUp.setUserId(userId); + + tbTptFollowUp.setCreatedBy(userName); + tbTptFollowUp.setUpdatedBy(userName); + + if (requestDTO.getFields() != null) { + + TbTptFollowUpListDTO fields = requestDTO.getFields(); + + tbTptFollowUp.setRegimenType(fields.getRegimenType()); + + if (fields.getTreatmentStartDate() != null + && !fields.getTreatmentStartDate().isEmpty()) { + + tbTptFollowUp.setTreatmentStartDate( + Timestamp.valueOf(fields.getTreatmentStartDate()) + ); + } + + if (fields.getExpectedTreatmentCompletionDate() != null + && !fields.getExpectedTreatmentCompletionDate().isEmpty()) { + + tbTptFollowUp.setExpectedTreatmentCompletionDate( + Timestamp.valueOf(fields.getExpectedTreatmentCompletionDate()) + ); + } + + if (fields.getFollowUpDate() != null + && !fields.getFollowUpDate().isEmpty()) { + + tbTptFollowUp.setFollowUpDate( + Timestamp.valueOf(fields.getFollowUpDate()) + ); + } + + tbTptFollowUp.setFollowUpMonth( + fields.getMonthlyFollowUp() + ); + + tbTptFollowUp.setAdherenceToMedicines( + fields.getMedicineAdherence() + ); + + tbTptFollowUp.setAnyDiscomfort( + convertBollen(fields.getAnyDiscomfort()) + ); + + tbTptFollowUp.setTreatmentCompleted( + convertBollen(fields.getTreatmentCompleted()) + ); + + if (fields.getActualCompletionDate() != null + && !fields.getActualCompletionDate().isEmpty()) { + + tbTptFollowUp.setActualTreatmentCompletionDate( + Timestamp.valueOf(fields.getActualCompletionDate()) + ); + } + + tbTptFollowUp.setTptOutcome( + fields.getTptOutcome() + ); + + if ("Death".equalsIgnoreCase(fields.getTptOutcome())) { + + if (fields.getDateOfDeath() != null + && !fields.getDateOfDeath().isEmpty()) { + + tbTptFollowUp.setDateOfDeath( + Timestamp.valueOf(fields.getDateOfDeath()) + ); + } + + tbTptFollowUp.setPlaceOfDeath( + fields.getPlaceOfDeath() + ); + + tbTptFollowUp.setReasonForDeath( + fields.getReasonForDeath() + ); + } + } + + tbTptFollowUpRepo.save(tbTptFollowUp); + + createTptIncentiveIfEligible(tbTptFollowUp); + } + + response.setResponse("TPT follow up saved successfully"); + + } catch (Exception e) { + + response.setError(500, e.getMessage()); + return response.toString(); + } + + return response.toString(); + } + + + private static final Map MIN_FOLLOW_UPS = Map.of( + "1HP", 1, + "3HP", 3, + "3RH", 3, + "4R", 4, + "6H", 6, + "6LFX", 6 + ); + + + private void createTptIncentiveIfEligible(TbTptFollowUp saved) { + + // 1. Basic conditions + if (!Boolean.TRUE.equals(saved.getTreatmentCompleted()) + || saved.getActualTreatmentCompletionDate() == null + || saved.getRegimenType() == null + || saved.getTreatmentStartDate() == null + || saved.getBenId() == null) { + return; + } + + // 2. Regimen ka minimum follow-up count + Integer minRequired = MIN_FOLLOW_UPS.get(saved.getRegimenType().trim().toUpperCase()); + if (minRequired == null) { + return; // unknown regimen + } + + List cycleRows = tbTptFollowUpRepo + .findByBenIdAndRegimenTypeAndTreatmentStartDate( + saved.getBenId(), + saved.getRegimenType(), + saved.getTreatmentStartDate()); + + long completedMonths = cycleRows.stream() + .map(TbTptFollowUp::getFollowUpMonth) + .filter(Objects::nonNull) + .map(String::trim) + .distinct() + .count(); + + if (completedMonths < minRequired) { + return; + } + Timestamp completionDate = saved.getActualTreatmentCompletionDate(); + + incentiveLogic.incentiveForTbPreventiveFollowUp( + saved.getBenId(), + completionDate, + completionDate, + saved.getUserId()); + } + + @Override + public String getTptFollowUp(String token) { + + OutputResponse response = new OutputResponse(); + + try { + + if (token == null || token.trim().isEmpty()) { + response.setError(400, "Token is required"); + return response.toString(); + } + + Integer userId = jwtUtil.extractUserId(token); + + List tptFollowUps = + tbTptFollowUpRepo.findByUserId(userId); + + List tptFollowUpDTOs = + new ArrayList<>(); + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + for (TbTptFollowUp tptFollowUp : tptFollowUps) { + + TbTptFollowUpDTO dto = new TbTptFollowUpDTO(); + + dto.setBenId(tptFollowUp.getBenId()); + dto.setUserId(tptFollowUp.getUserId()); + + TbTptFollowUpListDTO fields = + new TbTptFollowUpListDTO(); + + // Regimen Type + fields.setRegimenType( + tptFollowUp.getRegimenType() + ); + + // Treatment Start Date + if (tptFollowUp.getTreatmentStartDate() != null) { + fields.setTreatmentStartDate( + tptFollowUp.getTreatmentStartDate() + .toLocalDateTime() + .format(formatter) + ); + } + + // Expected Treatment Completion Date + if (tptFollowUp.getExpectedTreatmentCompletionDate() != null) { + fields.setExpectedTreatmentCompletionDate( + tptFollowUp.getExpectedTreatmentCompletionDate() + .toLocalDateTime() + .format(formatter) + ); + } + + // Follow Up Date + if (tptFollowUp.getFollowUpDate() != null) { + fields.setFollowUpDate( + tptFollowUp.getFollowUpDate() + .toLocalDateTime() + .format(formatter) + ); + } + + // Monthly Follow Up + fields.setMonthlyFollowUp( + tptFollowUp.getFollowUpMonth() + ); + + // Medicine Adherence + fields.setMedicineAdherence( + tptFollowUp.getAdherenceToMedicines() + ); + + // Any Discomfort + fields.setAnyDiscomfort( + convert(tptFollowUp.getAnyDiscomfort()) + ); + + // Treatment Completed + fields.setTreatmentCompleted( + convert(tptFollowUp.getTreatmentCompleted()) + ); + + // Actual Completion Date + if (tptFollowUp.getActualTreatmentCompletionDate() != null) { + fields.setActualCompletionDate( + tptFollowUp.getActualTreatmentCompletionDate() + .toLocalDateTime() + .format(formatter) + ); + } + + // TPT Outcome + fields.setTptOutcome( + tptFollowUp.getTptOutcome() + ); + + // Date of Death + if (tptFollowUp.getDateOfDeath() != null) { + fields.setDateOfDeath( + tptFollowUp.getDateOfDeath() + .toLocalDateTime() + .format(formatter) + ); + } + + // Place of Death + fields.setPlaceOfDeath( + tptFollowUp.getPlaceOfDeath() + ); + + // Reason for Death + fields.setReasonForDeath( + tptFollowUp.getReasonForDeath() + ); + + dto.setFields(fields); + + tptFollowUpDTOs.add(dto); + } + + ObjectMapper objectMapper = new ObjectMapper(); + + response.setResponse( + objectMapper.writeValueAsString(tptFollowUpDTOs) + ); + + } catch (Exception e) { + + e.printStackTrace(); + + response.setError( + 500, + "Failed to fetch TPT follow-up: " + e.getMessage() + ); + } + + return response.toString(); + } + + private Gson confirmedGson() { return new GsonBuilder() .registerTypeAdapter(LocalDate.class, new LocalDateAdapter()) @@ -271,4 +734,32 @@ private TBConfirmedCaseDTO toDTO(TBConfirmedCase entity) { return dto; } + + private String joinSideEffects(List sideEffects) { + return sideEffects.stream() + .filter(s -> s != null && !s.isBlank()) + .map(String::trim) + .collect(Collectors.joining(",")); + } + + private List splitSideEffects(String sideEffects) { + if (sideEffects == null) return new ArrayList<>(); + return Arrays.stream(sideEffects.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + private String convert(Boolean value) { + if (value == null) return null; + return value ? "Yes" : "No"; + } + + private Boolean convertBollen(String value) { + if (value != null && !value.isEmpty()) { + return value.equalsIgnoreCase("Yes"); + } else { + return false; + } + } } 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 2/6] 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 bc1edc372..3fd8a5257 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 4b144d60a..f42422060 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 b34acf9a8..03f3e1ddd 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 3/6] 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 03f3e1ddd..66e76fe10 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 4/6] 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 36ecc6420..bba6e3c63 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 cfb0665db..3f4279737 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 000000000..43b4c20a0 --- /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 3c00b1973..2100313ba 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 000000000..0422bc957 --- /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)); + } +} From 5572b225b62e7908e42d8eea6354c1ae432ef644 Mon Sep 17 00:00:00 2001 From: Mithun James <1007084+drtechie@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:48:53 +0530 Subject: [PATCH 5/6] fix(sync): report hub-side referral order creation in the changes feed (STOP-774) Each feed page now carries createsReferralOrders (true on a camp hub). The tablet stops placing screening referral orders itself only after it sees this flag, so a hub older than this release keeps getting the tablet's push and no referral is lost if the app is updated first. Co-Authored-By: Claude Opus 5.5 --- .../com/iemr/flw/service/impl/StopTBSyncServiceImpl.java | 7 +++++++ 1 file changed, 7 insertions(+) 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 f42422060..cd27620b9 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java @@ -5,6 +5,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonParser; import com.iemr.flw.service.BeneficiaryService; +import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.DiagnosticOrderService; import com.iemr.flw.service.DynamicFormResponseService; import com.iemr.flw.service.StopTBService; @@ -76,6 +77,8 @@ public class StopTBSyncServiceImpl implements StopTBSyncService { private DiagnosticOrderService diagnosticOrderService; @Autowired private ObjectMapper objectMapper; + @Autowired + private CampConfigService campConfigService; private static final long USER_SCOPE_TTL_MS = 5 * 60 * 1000L; private final Map> userScopes = new java.util.concurrent.ConcurrentHashMap<>(); @@ -353,6 +356,10 @@ private Map page(String serverId, boolean snapshot, List Date: Mon, 9 Nov 2026 09:48:53 +0530 Subject: [PATCH 6/6] fix(sync): build feed beneficiary records from the requested registration Feed beneficiary records were built from the newest mapping at each address. When two registrations share an address ID, that mapping could belong to the other registration, so the requested record was missing and the feed moved past it. The registration ID now travels with its address, the record is built from that registration's mapping, and the prefetch loads that mapping's rows. Co-Authored-By: Claude Opus 5.5 --- .../service/impl/BeneficiaryServiceImpl.java | 39 ++++++++++++++---- .../service/impl/WorklistPagePrefetch.java | 40 ++++++++++++++----- 2 files changed, 61 insertions(+), 18 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 3fd8a5257..647dd6876 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -267,22 +267,25 @@ private String getMappingsForAddressIDs(List addressLi @Override public Map getBeneficiaryRecordsByRegIds(List benRegIds, String authorisation) { // Two queries for the whole page; the per-beneficiary lookups stay as the fallback. - List addresses; + Map addressByRegId; try { - addresses = worklistPagePrefetch.addressesForRegIds(benRegIds); + addressByRegId = worklistPagePrefetch.addressesForRegIds(benRegIds); } catch (Exception e) { logger.warn("Beneficiary address prefetch failed, using per-beneficiary queries: " + e.getMessage()); - addresses = new ArrayList<>(); + addressByRegId = new LinkedHashMap<>(); 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); + if (a != null) addressByRegId.putIfAbsent(benRegId, a); } } Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy h:mm:ss a").create(); Map records = new LinkedHashMap<>(); - for (Map record : buildBeneficiaryRecords(addresses, authorisation)) { + // The registration each address was looked up for is passed along, so the record is built + // from that registration's mapping even when another registration shares the address ID. + for (Map record : buildBeneficiaryRecords(new ArrayList<>(addressByRegId.values()), + new ArrayList<>(addressByRegId.keySet()), authorisation)) { Object benRegId = record.get("BenRegId"); if (benRegId != null) records.put(new BigInteger(benRegId.toString()).longValue(), gson.toJson(record)); } @@ -291,6 +294,16 @@ public Map getBeneficiaryRecordsByRegIds(List benRegIds, Str private List> buildBeneficiaryRecords(List addressList, String authorisation) { + return buildBeneficiaryRecords(addressList, null, authorisation); + } + + // requestedRegIds, when given, holds the registration each address (same index) was looked up + // for: the record is built from that registration's mapping, and the address is skipped if it + // has none, instead of using the newest mapping at the address, which may belong to another + // registration that shares the address ID. + private List> buildBeneficiaryRecords(List addressList, + List requestedRegIds, + String authorisation) { RMNCHHouseHoldDetails benHouseHoldRMNCH_ROBJ; RMNCHBeneficiaryDetailsRmnch benDetailsRMNCH_OBJ; RMNCHBornBirthDetails benBotnBirthRMNCH_ROBJ; @@ -308,16 +321,28 @@ private List> buildBeneficiaryRecords(List(requestedRegIds)); } catch (Exception e) { logger.warn("Worklist page prefetch failed, using per-beneficiary queries: " + e.getMessage()); } - for (RMNCHMBeneficiaryaddress a : addressList) { + for (int idx = 0; idx < addressList.size(); idx++) { + RMNCHMBeneficiaryaddress a = addressList.get(idx); // exception by-passing try { List mappingsForAddress = pf != null ? pf.mappings(a.getId()) : beneficiaryRepo.getByAddressID(a.getId()); + if (requestedRegIds != null) { + Long wanted = requestedRegIds.get(idx); + mappingsForAddress = mappingsForAddress.stream() + .filter(mp -> mp.getBenRegId() != null && wanted != null + && mp.getBenRegId().longValue() == wanted) + .limit(1).collect(Collectors.toList()); + if (mappingsForAddress.isEmpty()) { + logger.warn("No mapping for benRegID=" + wanted + " at its address; record left out"); + } + } if(!mappingsForAddress.isEmpty()){ RMNCHMBeneficiarymapping m = mappingsForAddress.get(0); if (m != null) { 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 66e76fe10..20f3db962 100644 --- a/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java +++ b/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java @@ -77,6 +77,15 @@ public List addressesInOrder(List ids) { } public Page load(List pageAddresses) { + return load(pageAddresses, Collections.emptySet()); + } + + /** + * As {@link #load(List)}, and also loads the rows of any mapping belonging to one of + * requestedRegIds, so a caller that picks the requested registration's mapping (rather than + * the newest one at the address) finds its details here too. + */ + public Page load(List pageAddresses, Set requestedRegIds) { Page page = new Page(); pageAddresses.forEach(a -> page.addresses.put(a.getBenAddressID(), a)); // The builder looks mappings up by address.getId(), which is the VanSerialNo column, not the @@ -90,9 +99,17 @@ public Page load(List pageAddresses) { "SELECT * FROM db_identity.i_beneficiarymapping WHERE BenAddressId IN (%s) ORDER BY BenMapId DESC", lookupIds)) { page.mappingsByAddress.computeIfAbsent(m.getBenAddressId(), k -> new ArrayList<>()).add(m); } - // the builder only uses the first (newest) mapping of each address - List used = page.mappingsByAddress.values().stream() - .map(l -> l.get(0)).collect(Collectors.toList()); + // the builder uses the first (newest) mapping of each address, or the requested + // registration's mapping when it was asked for specific registrations + List used = new ArrayList<>(); + for (List l : page.mappingsByAddress.values()) { + for (int i = 0; i < l.size(); i++) { + RMNCHMBeneficiarymapping m = l.get(i); + if (i == 0 || (m.getBenRegId() != null && requestedRegIds.contains(m.getBenRegId().longValue()))) { + used.add(m); + } + } + } putAll(page.details, entities(identityEm, RMNCHMBeneficiarydetail.class, "SELECT * FROM db_identity.i_beneficiarydetails WHERE BeneficiaryDetailsId IN (%s)", @@ -154,13 +171,14 @@ 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. 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. + * The address row of each beneficiary keyed by its registration ID, in the order of regIds, + * using one query for the mappings 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. The registration ID stays with + * its address because an address ID can be shared by mappings of different registrations. */ - public List addressesForRegIds(List regIds) { - if (regIds.isEmpty()) return Collections.emptyList(); + public LinkedHashMap addressesForRegIds(List regIds) { + if (regIds.isEmpty()) return new LinkedHashMap<>(); Map addressIdByRegId = new HashMap<>(); for (Object[] r : rows(identityEm, "SELECT BenRegId, BenAddressId FROM db_identity.i_beneficiarymapping WHERE BenRegId IN (%s) ORDER BY BenMapId DESC", @@ -171,11 +189,11 @@ public List addressesForRegIds(List regIds) { 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<>(); + LinkedHashMap out = new LinkedHashMap<>(); for (Long regId : regIds) { BigInteger addressId = addressIdByRegId.get(regId); RMNCHMBeneficiaryaddress a = addressId == null ? null : addressById.get(addressId); - if (a != null) out.add(a); + if (a != null) out.putIfAbsent(regId, a); } return out; }