Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
bee2f14
Adding locking for multiple order creation
sehjotsinghunthinkable Oct 13, 2026
43f1ca1
Merge pull request #375 from sehjotsinghunthinkable/feature/locking-s…
vishwab1 Oct 13, 2026
8d18e8d
feat(stoptb): save OPD prescription to standard tables and dispense s…
vishwab1 Oct 8, 2026
f403952
fix(stoptb): close dispensing gaps found in review
vishwab1 Oct 8, 2026
5ad0b40
feat(stoptb): General OPD on AMRIT standard tables only
vishwab1 Oct 8, 2026
727cfdd
feat(stoptb): calculate tablet/capsule quantity when the app sends none
vishwab1 Oct 8, 2026
2046861
fix(stoptb): OPD save validations from final review
vishwab1 Oct 8, 2026
3bb529f
feat(stoptb): default quantity 1 for non-tablet medicines when the ap…
vishwab1 Oct 8, 2026
b050820
feat(stoptb): getAll drugs list with form; SOS quantity default
vishwab1 Oct 8, 2026
f3a8a1d
Dropping duplicate columns in table tb_stoptb_general_examination
sehjotsinghunthinkable Sep 15, 2026
d5f07a2
Adding migration applier and removing column migration file
sehjotsinghunthinkable Sep 22, 2026
9d0e7cc
feat: add dynamic form migrator and contact tracing migration
chetansaroya Sep 24, 2026
e9d49bb
feat: change the flw-api version to 3.9.1
chetansaroya Sep 24, 2026
65825aa
feat: update Stop TB service implementation
chetansaroya Sep 24, 2026
4dc14b6
feat: add dynamic form migrator with new form version creation
chetansaroya Sep 24, 2026
bddbd41
feat: refactored code
chetansaroya Sep 24, 2026
3fb7d2a
feat: changed flw-api version to 3.12.0
chetansaroya Sep 24, 2026
b80b4a6
Updated the device integration according to updated workflow
sehjotsinghunthinkable Sep 28, 2026
4b6da82
Updated the device integration according to updated workflow
sehjotsinghunthinkable Sep 28, 2026
2272563
Updated the device integration according to updated workflow
sehjotsinghunthinkable Sep 29, 2026
f746af1
Updated the device integration according to updated workflow
sehjotsinghunthinkable Sep 29, 2026
d55f00c
Updated the device integration according to updated workflow
sehjotsinghunthinkable Sep 29, 2026
3e3159e
Updated the device integration according to updated workflow
sehjotsinghunthinkable Sep 30, 2026
272a97c
feat: stop-584 dynamic form backward compatibility changes
chetansaroya Oct 5, 2026
ad6493a
feat: stop-542 extra fields consumer changes
chetansaroya Oct 6, 2026
cc06192
Revert "feat: stop-542 extra fields consumer changes"
chetansaroya Oct 6, 2026
5cbbf04
feat: stop-542 extra fields consumer changes
chetansaroya Oct 6, 2026
b554369
fix(stoptb): fill missing service line on the day's visit
vishwab1 Oct 12, 2026
c595908
Removing gpsLatitude and gpsLongitude, and setting values into latitu…
sehjotsinghunthinkable Oct 9, 2026
9e4fab9
Adding locking for multiple order creation
sehjotsinghunthinkable Oct 9, 2026
bd72175
Adding locking for multiple order creation
sehjotsinghunthinkable Oct 9, 2026
863d8c2
fix(stoptb): MMU dose-based quantity; no batch re-upload after dispense
vishwab1 Oct 16, 2026
032c0f0
fix(stoptb): send the batch's new stock to central after an OPD dispense
vishwab1 Oct 20, 2026
396b2fc
Changes for STOP-711
sehjotsinghunthinkable Oct 22, 2026
71199b3
Merge remote-tracking branch 'upstream/release-3.12.0' into feature/S…
sehjotsinghunthinkable Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@
public class EmrLiteRestTemplateConfig {

private static final int VENDOR_CALL_TIMEOUT_MS = 20000;
// Result responses carry document assets inline as base64, so reads get more headroom than connects.
private static final int VENDOR_READ_TIMEOUT_MS = 60000;
private static final int PING_TIMEOUT_MS = 5000;

// Timeouts are required, not optional: order pushes run while holding the per-beneficiary+orderType
// order lock (see DiagnosticOrderServiceImpl), so a hung vendor call without them would hold that lock forever.
@Bean
public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(VENDOR_CALL_TIMEOUT_MS);
factory.setReadTimeout(VENDOR_READ_TIMEOUT_MS);
RestTemplate restTemplate = new RestTemplate(factory);
restTemplate.getInterceptors().add(new EmrLiteAuthInterceptor(emrLiteTokenManager));
return restTemplate;
Expand All @@ -28,8 +35,8 @@ public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager)
@Bean
public RestTemplate emrLitePingRestTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(VENDOR_CALL_TIMEOUT_MS);
factory.setReadTimeout(VENDOR_CALL_TIMEOUT_MS);
factory.setConnectTimeout(PING_TIMEOUT_MS);
factory.setReadTimeout(PING_TIMEOUT_MS);
return new RestTemplate(factory);
}
}
13 changes: 9 additions & 4 deletions src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;

Expand All @@ -25,20 +26,24 @@ Optional<DiagnosticOrder> findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrd
@Query("SELECT o FROM DiagnosticOrder o WHERE o.beneficiaryId = :beneficiaryId AND o.deleted = false ORDER BY o.createdDate DESC")
List<DiagnosticOrder> findByBeneficiaryId(@Param("beneficiaryId") Long beneficiaryId);

// Polling and the daily close only touch orders created within [dayStart, dayEnd) — i.e. today's.
@Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType = 'XRAY_CHEST' " +
"AND o.status IN ('PENDING', 'IN_PROGRESS') " +
"AND o.deleted = false " +
"AND o.createdDate >= :dayStart AND o.createdDate < :dayEnd " +
"ORDER BY o.lastPolledAt ASC NULLS FIRST")
List<DiagnosticOrder> findXrayDueForPoll();
List<DiagnosticOrder> findXrayDueForPoll(@Param("dayStart") Timestamp dayStart, @Param("dayEnd") Timestamp dayEnd);

@Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType IN ('MTB', 'MTB_PLUS', 'MDR_RIF') " +
"AND o.status IN ('PENDING', 'IN_PROGRESS') " +
"AND o.deleted = false " +
"AND o.createdDate >= :dayStart AND o.createdDate < :dayEnd " +
"ORDER BY o.lastPolledAt ASC NULLS FIRST")
List<DiagnosticOrder> findTrueNatDueForPoll();
List<DiagnosticOrder> findTrueNatDueForPoll(@Param("dayStart") Timestamp dayStart, @Param("dayEnd") Timestamp dayEnd);

@Query("SELECT o FROM DiagnosticOrder o WHERE o.status IN ('PENDING', 'IN_PROGRESS') AND o.deleted = false")
List<DiagnosticOrder> findAllOutstandingOrders();
@Query("SELECT o FROM DiagnosticOrder o WHERE o.status IN ('PENDING', 'IN_PROGRESS') AND o.deleted = false " +
"AND o.createdDate >= :dayStart AND o.createdDate < :dayEnd")
List<DiagnosticOrder> findAllOutstandingOrders(@Param("dayStart") Timestamp dayStart, @Param("dayEnd") Timestamp dayEnd);

@Transactional
@Modifying
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@
import com.iemr.flw.integration.provider.DiagnosticPushResult;
import com.iemr.flw.masterEnum.DiagnosticOrderStatus;
import com.iemr.flw.masterEnum.DiagnosticOrderType;
import com.iemr.flw.dto.iemr.UserServiceRoleDTO;
import com.iemr.flw.repo.identity.BeneficiaryRepo;
import com.iemr.flw.repo.iemr.DiagnosticOrderRepo;
import com.iemr.flw.repo.iemr.DiagnosticResultRepo;
import com.iemr.flw.repo.iemr.EmployeeMasterRepo;
import com.iemr.flw.repo.iemr.TBSuspectedRepo;
import com.iemr.flw.repo.iemr.UserServiceRoleRepo;
import com.iemr.flw.service.CampConfigService;
import com.iemr.flw.service.DiagnosticDocumentService;
import com.iemr.flw.service.DiagnosticOrderService;
import com.iemr.flw.service.TBStopVisitService;
import com.iemr.flw.utils.JwtUtil;
import com.google.common.util.concurrent.Striped;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -43,7 +43,9 @@
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
public class DiagnosticOrderServiceImpl implements DiagnosticOrderService {
Expand Down Expand Up @@ -123,19 +125,36 @@ public DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto requ
// two simultaneous pushes for the same beneficiary could both pass the dedup check (or both create a
// visit for today) and send the same patient to the vendor twice — shown as duplicate names on the
// TrueNat machine. Keyed by beneficiary, not orderType, because the visit is shared across order types.
// (release-3.9.3 keys its lock by beneficiary+orderType because there the client supplies the
// visitCode; here the server creates the visit, so the key must be the beneficiary alone.)
// In-memory, so it only serialises within this JVM: correct while orders are created only by the
// single van server that can reach the vendor. Striped keeps the lock registry a fixed size for the
// JVM's lifetime: the same beneficiary always maps to the same stripe, while unrelated beneficiaries
// that happen to share a stripe merely wait on each other briefly. Stripes are reentrant, and no path
// ever holds two beneficiaries' locks at once, so sharing a stripe can't deadlock.
private static final int BENEFICIARY_LOCK_STRIPES = 1024;
private final Striped<Lock> beneficiaryOrderLocks = Striped.lock(BENEFICIARY_LOCK_STRIPES);
// single van server that can reach the vendor. Same mechanism as release-3.9.3: one ReentrantLock per
// beneficiary, held weakly so an idle beneficiary's lock is garbage-collected — any thread waiting on
// or holding it keeps a strong reference, so concurrent callers always share the same instance.
// User requests wait at most LOCK_WAIT_SECONDS and then fail with a retry message, instead of piling
// up behind a slow vendor push (bounded by the EmrLiteRestTemplateConfig timeouts).
private final LoadingCache<Long, Lock> beneficiaryOrderLocks = CacheBuilder.newBuilder()
.weakValues()
.build(CacheLoader.from(beneficiaryId -> new ReentrantLock()));

private static final long LOCK_WAIT_SECONDS = 30;

private <T> T withBeneficiaryLock(Long beneficiaryId, Callable<T> action) throws Exception {
if (beneficiaryId == null) {
throw new IllegalArgumentException("beneficiaryId is required");
Lock lock = beneficiaryLock(beneficiaryId);
if (!lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS)) {
throw new IllegalStateException("Another diagnostic order operation is still in progress for beneficiaryId="
+ beneficiaryId + " — please retry shortly");
}
try {
return action.call();
} finally {
lock.unlock();
}
Lock lock = beneficiaryOrderLocks.get(beneficiaryId);
}

// For background work with no caller to retry it (the automatic retest): waits as long as it takes.
private <T> T withBeneficiaryLockBlocking(Long beneficiaryId, Callable<T> action) throws Exception {
Lock lock = beneficiaryLock(beneficiaryId);
lock.lock();
try {
return action.call();
Expand All @@ -144,6 +163,13 @@ private <T> T withBeneficiaryLock(Long beneficiaryId, Callable<T> action) throws
}
}

private Lock beneficiaryLock(Long beneficiaryId) {
if (beneficiaryId == null) {
throw new IllegalArgumentException("beneficiaryId is required");
}
return beneficiaryOrderLocks.getUnchecked(beneficiaryId);
}

private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, String createdBy) throws Exception {
return withBeneficiaryLock(request.getBeneficiaryId(), () -> createAndPushOrderLocked(request, createdBy));
}
Expand Down Expand Up @@ -239,11 +265,6 @@ private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto reque
}
throw dive;
}
// Stamp the sync key before the (possibly slow) vendor push, so a crash/restart mid-push can't leave it NULL.
if (order.getVanSerialNo() == null) {
diagnosticOrderRepo.updateVanSerialNo(order.getId());
order.setVanSerialNo(order.getId());
}

if (noVendor) {
logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry",
Expand Down Expand Up @@ -531,9 +552,7 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception {
order.setLastPolledAt(new Timestamp(System.currentTimeMillis()));
order.setModifiedBy("SYSTEM");
order.setProcessed("N");
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());
diagnosticOrderRepo.save(order);
return null;
}
DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode());
Expand Down Expand Up @@ -750,7 +769,7 @@ private static boolean isInvalidResult(String orderTypeCode, String resultSummar
// pushed to the vendor straight away. Never throws — a failure here must not undo the close.
private void pushRetestOrder(DiagnosticOrder closed) {
try {
withBeneficiaryLock(closed.getBeneficiaryId(), () -> {
withBeneficiaryLockBlocking(closed.getBeneficiaryId(), () -> {
pushRetestOrderLocked(closed);
return null;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.iemr.flw.domain.iemr.DiagnosticOrder;
import com.iemr.flw.integration.provider.DiagnosticProviderFactory;
import com.iemr.flw.masterEnum.DiagnosticOrderStatus;
import com.iemr.flw.masterEnum.DiagnosticOrderType;
import com.iemr.flw.repo.iemr.DiagnosticOrderRepo;
import com.iemr.flw.service.DiagnosticOrderService;
Expand All @@ -14,15 +13,9 @@

import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.function.Supplier;

// Every due order (status PENDING) is polled on every tick from the moment it's created —
// there is no per-order initial delay or rolling give-up window anymore. A tick is skipped entirely
// for an order-type family if diagnostic.provider.xray / diagnostic.provider.truenat has no active
// vendor configured right now (checked live via DiagnosticProviderFactory, not any value stored on the
// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING (for
// order-type families that do have an active vendor) and marks it CLOSED.
@Service
public class DiagnosticPollSchedulerService {

Expand All @@ -39,73 +32,98 @@ public class DiagnosticPollSchedulerService {

@Scheduled(fixedDelayString = "${diagnostic.poll.xray.tick-ms:15000}")
public void pollXrayOrders() {
if (noActiveVendor(DiagnosticOrderType.XRAY_CHEST)) {
logger.debug("No active vendor configured for XRAY_CHEST — skipping poll tick");
return;
}
List<DiagnosticOrder> candidates = diagnosticOrderRepo.findXrayDueForPoll();
logger.info("XRAY poll tick: {} order(s) in due-for-poll queue", candidates.size());
if (candidates.isEmpty()) return;

for (DiagnosticOrder order : candidates) {
pollSingle(order);
}
logger.info("Polled {} pending XRAY diagnostic orders", candidates.size());
pollDueOrders("XRAY", DiagnosticOrderType.XRAY_CHEST,
() -> diagnosticOrderRepo.findXrayDueForPoll(startOfToday(), startOfTomorrow()));
}

@Scheduled(fixedDelayString = "${diagnostic.poll.truenat.tick-ms:60000}")
public void pollTrueNatOrders() {
if (noActiveVendor(DiagnosticOrderType.MTB)) {
logger.debug("No active vendor configured for TrueNat — skipping poll tick");
pollDueOrders("TrueNat", DiagnosticOrderType.MTB,
() -> diagnosticOrderRepo.findTrueNatDueForPoll(startOfToday(), startOfTomorrow()));
}

private void pollDueOrders(String label, DiagnosticOrderType orderType, Supplier<List<DiagnosticOrder>> dueOrders) {
String providerCode = providerFactory.getProviderCodeForOrderType(orderType);
if (providerCode == null || providerCode.isBlank()) {
logger.debug("No active vendor configured for {} — skipping poll tick", label);
return;
}
List<DiagnosticOrder> candidates = diagnosticOrderRepo.findTrueNatDueForPoll();
logger.info("TrueNat poll tick: {} order(s) in due-for-poll queue", candidates.size());
List<DiagnosticOrder> candidates = dueOrders.get();
logger.info("{} poll tick: {} order(s) in due-for-poll queue", label, candidates.size());
if (candidates.isEmpty()) return;

for (DiagnosticOrder order : candidates) {
pollSingle(order);
if (!providerFactory.getProvider(providerCode).checkHealth(orderType)) {
logger.warn("{} vendor ping failed — skipping poll tick, {} order(s) left for the next tick",
label, candidates.size());
return;
}
logger.info("Polled {} pending TrueNat diagnostic orders", candidates.size());
}

@Scheduled(cron = "${diagnostic.poll.expiry-cron:0 50 23 * * *}")
public void expireOutstandingOrders() {
List<DiagnosticOrder> outstanding = diagnosticOrderRepo.findAllOutstandingOrders();
int expired = 0;
for (DiagnosticOrder order : outstanding) {
if (noActiveVendor(DiagnosticOrderType.fromCode(order.getOrderType()))) {
continue; // awaiting manual entry — not subject to the polling cutoff
int polled = 0;
for (DiagnosticOrder order : candidates) {
polled++;
if (!pollSingle(order)) {
logger.warn("{} vendor unreachable mid-tick — stopping, {} order(s) left for the next tick",
label, candidates.size() - polled);
break;
}
expire(order);
expired++;
}
if (expired > 0) {
logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) CLOSED", expired);
}
logger.info("Polled {} of {} pending {} diagnostic orders", polled, candidates.size(), label);
}

// Auto-closure of outstanding orders at the daily cutoff is disabled for now — orders are no longer
// closed automatically. Uncomment this job (and expire() below) to re-enable it.
// @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 50 23 * * *}")
// public void expireOutstandingOrders() {
// List<DiagnosticOrder> outstanding = diagnosticOrderRepo.findAllOutstandingOrders(startOfToday(), startOfTomorrow());
// int expired = 0;
// for (DiagnosticOrder order : outstanding) {
// if (noActiveVendor(DiagnosticOrderType.fromCode(order.getOrderType()))) {
// continue; // awaiting manual entry — not subject to the polling cutoff
// }
// expire(order);
// expired++;
// }
// if (expired > 0) {
// logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) CLOSED", expired);
// }
// }

// Polling and the daily close only handle orders created today. JVM default zone on purpose: it's the
// same clock created_date is stamped with (@CreationTimestamp), so the day boundaries line up.
private static Timestamp startOfToday() {
return Timestamp.valueOf(LocalDate.now().atStartOfDay());
}

private static Timestamp startOfTomorrow() {
return Timestamp.valueOf(LocalDate.now().plusDays(1).atStartOfDay());
}

private boolean noActiveVendor(DiagnosticOrderType orderType) {
String providerCode = providerFactory.getProviderCodeForOrderType(orderType);
return providerCode == null || providerCode.isBlank();
}

private void expire(DiagnosticOrder order) {
String reason = "Order validity expired for the day " + LocalDate.now(ZoneId.of("Asia/Kolkata"));
order.setStatus(DiagnosticOrderStatus.CLOSED.name());
order.setReasonToClose(reason);
order.setLastPolledAt(new Timestamp(System.currentTimeMillis()));
order.setModifiedBy("SYSTEM");
order.setProcessed("N");
diagnosticOrderRepo.save(order);
diagnosticOrderService.notifyProviderOrderClosed(order, reason);
}
// Used only by the (disabled) auto-closure job above.
// private void expire(DiagnosticOrder order) {
// String reason = "Order validity expired for the day " + LocalDate.now(ZoneId.of("Asia/Kolkata"));
// order.setStatus(DiagnosticOrderStatus.CLOSED.name());
// order.setReasonToClose(reason);
// order.setLastPolledAt(new Timestamp(System.currentTimeMillis()));
// order.setModifiedBy("SYSTEM");
// order.setProcessed("N");
// diagnosticOrderRepo.save(order);
// diagnosticOrderService.notifyProviderOrderClosed(order, reason);
// }

private void pollSingle(DiagnosticOrder order) {
// Returns false only when the vendor couldn't be reached at all (connect/read timeout, refused
// connection) — the caller stops the tick then. Any other failure is specific to this order
// (bad data, vendor-side rejection), so it's recorded and the tick carries on.
private boolean pollSingle(DiagnosticOrder order) {
try {
// pollOnce now saves the result/order fields and ingests any documents itself —
// nothing left to do here on success.
diagnosticOrderService.pollOnce(order);
return true;
} catch (Exception e) {
logger.error("Poll failed for orderId={}: {}", order.getId(), e.getMessage());
order.setRetryCount(order.getRetryCount() + 1);
Expand All @@ -114,6 +132,8 @@ private void pollSingle(DiagnosticOrder order) {
order.setModifiedBy("SYSTEM");
order.setProcessed("N");
diagnosticOrderRepo.save(order);
return false;
}
}

}
Loading