From 599d092ac28f334dd0ef73110fdb46d7ca96b62d Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 28 Sep 2026 19:23:46 +0530 Subject: [PATCH 1/4] Updated the device integration according to updated workflow --- src/main/environment/common_ci.properties | 2 + src/main/environment/common_docker.properties | 2 + .../environment/common_example.properties | 42 +-- .../controller/DiagnosticOrderController.java | 20 +- .../iemr/flw/domain/iemr/DiagnosticOrder.java | 13 +- .../flw/dto/DiagnosticOrderRequestDto.java | 5 +- .../flw/dto/DiagnosticOrderResultDto.java | 2 +- .../dto/DiagnosticOrderStatusSummaryDto.java | 3 +- .../dto/ManualDiagnosticResultRequestDto.java | 4 +- .../provider/DiagnosticCancelResult.java | 14 + .../provider/DiagnosticProvider.java | 10 + .../provider/emrlite/EmrLiteProvider.java | 39 ++- .../emrlite/dto/EmrLiteCancelRequest.java | 13 + .../flw/masterEnum/DiagnosticOrderStatus.java | 7 +- .../flw/repo/iemr/DiagnosticOrderRepo.java | 23 +- .../flw/service/DiagnosticOrderService.java | 18 +- .../impl/DiagnosticDocumentServiceImpl.java | 2 +- .../impl/DiagnosticOrderServiceImpl.java | 239 ++++++++++++++---- .../impl/DiagnosticPollSchedulerService.java | 20 +- 19 files changed, 351 insertions(+), 127 deletions(-) create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 0bd9638b1..ad996282d 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -68,6 +68,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=@env.DIAGNOSTIC_EMRLITE_XRAY_USERNAME@ diagnostic.emrlite.xray.password=@env.DIAGNOSTIC_EMRLITE_XRAY_PASSWORD@ @@ -78,6 +79,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=@env.DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME@ diagnostic.emrlite.truenat.password=@env.DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 2b19223c7..b37a8e00e 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -67,6 +67,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} @@ -77,6 +78,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 9238d8be3..9583947c9 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -7,14 +7,14 @@ tm-url=http://localhost:8089/ spring.datasource.url=jdbc:mysql://localhost:3306/db_iemr spring.datasource.username=root -spring.datasource.password=1234 +spring.datasource.password=Hrhk@4321 spring.datasource.driver-class-name=com.mysql.jdbc.Driver ##--------------------------------------------## Secondary db------------------------------------------------------------------- secondary.datasource.url=jdbc:mysql://localhost:3306/db_identity secondary.datasource.username=root -secondary.datasource.password=1234 +secondary.datasource.password=Hrhk@4321 secondary.datasource.driver-class-name=com.mysql.jdbc.Driver springdoc.api-docs.enabled=true @@ -32,7 +32,7 @@ spring.redis.host=localhost # instead of silently storing vanID=NULL (which then never gets synced to central) stoptb.enforce.vanid=false # Stop TB: this deployment's van/camp ID, replacing the old Redis camp:vanID lookup -stoptb.van.id=0 +stoptb.van.id=1 cors.allowed-origins=http://localhost:* @@ -49,40 +49,46 @@ abha.client.secret= abha.token.url= abha.xcmid= +# GovThealth API Config +govthealth.user.details.url=$GOVTHEALTH_USER_DETAILS_URL} +govthealth.user.id=$GOVTHEALTH_USER_ID} +govthealth.password=$GOVTHEALTH_PASSWORD} # ── Diagnostic Device Integration ──────────────────────────────────────────── # diagnostic.provider.xray / diagnostic.provider.truenat select the vendor per orderType family # (XRAY_CHEST vs MTB/MTB_PLUS/MDR_RIF), each must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode -diagnostic.provider.xray=${DIAGNOSTIC_PROVIDER_XRAY} -diagnostic.provider.truenat=${DIAGNOSTIC_PROVIDER_TRUENAT} +diagnostic.provider.xray=EMRLITE +diagnostic.provider.truenat=EMRLITE # EMR Lite - xray group (same base-url as truenat for now; split so a distinct vendor/tenant can be # swapped in for either group later without a code change) -diagnostic.emrlite.xray.base-url=${DIAGNOSTIC_EMRLITE_XRAY_BASE_URL} +diagnostic.emrlite.xray.base-url=http://152.67.167.101:8000 diagnostic.emrlite.xray.login-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/login/ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ -diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} -diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ +diagnostic.emrlite.xray.username=piramal +diagnostic.emrlite.xray.password=piramal@123 # EMR Lite - truenat group (MTB/MTB_PLUS/MDR_RIF); same value as xray for now -diagnostic.emrlite.truenat.base-url=${DIAGNOSTIC_EMRLITE_TRUENAT_BASE_URL} +diagnostic.emrlite.truenat.base-url=http://152.67.167.101:8000 diagnostic.emrlite.truenat.login-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/login/ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ -diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} -diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ +diagnostic.emrlite.truenat.username=piramal +diagnostic.emrlite.truenat.password=piramal@123 -diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} -diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} +diagnostic.emrlite.token-ttl-seconds=3600 +diagnostic.emrlite.refresh-ttl-seconds=36000 -diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} -diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} -# Daily cutoff (cron expression) after which all outstanding PENDING/IN_PROGRESS orders are marked EXPIRED -diagnostic.poll.expiry-cron=${DIAGNOSTIC_POLL_EXPIRY_CRON} +diagnostic.poll.xray.tick-ms=30000 +diagnostic.poll.truenat.tick-ms=60000 +# Daily cutoff (cron expression) after which all outstanding PENDING orders are marked CLOSED +diagnostic.poll.expiry-cron=0 50 23 * * * -diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} +diagnostic.documents.storage-root=/home/unthinkable-lap/Desktop/diagnostic-documt diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java index 8eb9476b7..1725b5afc 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -37,10 +37,11 @@ public DiagnosticOrderController(DiagnosticOrderService diagnosticOrderService) @PostMapping("/order/push") @Operation(summary = "Push a diagnostic order to the configured provider") - public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { + public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrder order = diagnosticOrderService.createAndPushOrder(request); + DiagnosticOrder order = diagnosticOrderService.createAndPushOrderByUser(request, jwtToken); response.setResponse(new Gson().toJson(order)); } catch (Exception e) { logger.error("Error in pushOrder: {}", e.getMessage()); @@ -128,14 +129,17 @@ public String pollOrder(@RequestParam Long beneficiaryId, @RequestParam String o } @PostMapping("/order/manualResult") - @Operation(summary = "Manually submit a diagnostic result for a beneficiary's latest order of the given " - + "type, for use when no vendor device is integrated. Stored as-is; tbPresence, tbConfidence, and " - + "drugResistancePresence are left null since nothing derives them. Rejected if that order is " - + "already COMPLETED.") - public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request) { + @Operation(summary = "Exactly one of resultSummary/reasonToClose is required. resultSummary: manually submit " + + "a diagnostic result for a beneficiary's latest order of the given type, for use when no vendor " + + "device is integrated. Stored as-is; tbPresence, tbConfidence, and drugResistancePresence are left " + + "null since nothing derives them. Rejected if that order is already COMPLETED. reasonToClose: close " + + "the order instead (same behavior as reasonToClose on /order/push), sourcing patient/orderEvent " + + "from the beneficiary's existing order for this type.") + public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request); + DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request, jwtToken); response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in submitManualResult: {}", e.getMessage()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java index 197999fe2..f8f69e037 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -8,8 +8,7 @@ import java.sql.Timestamp; @Entity -@Table(name = "tb_diagnostic_order", schema = "db_iemr", - uniqueConstraints = @UniqueConstraint(columnNames = {"beneficiary_id", "visitCode", "order_type"})) +@Table(name = "tb_diagnostic_order", schema = "db_iemr") @Data public class DiagnosticOrder { @@ -53,12 +52,15 @@ public class DiagnosticOrder { @Column(name = "error_message", columnDefinition = "TEXT") private String errorMessage; - @Column(name = "reason_for_refusal", columnDefinition = "TEXT") - private String reasonForRefusal; + @Column(name = "reason_to_close", columnDefinition = "TEXT") + private String reasonToClose; @Column(name = "push_response_json", columnDefinition = "LONGTEXT") private String pushResponseJson; + @Column(name = "cancel_response_json", columnDefinition = "LONGTEXT") + private String cancelResponseJson; + @Column(name = "patient_first_name", length = 100) private String patientFirstName; @@ -82,6 +84,9 @@ public class DiagnosticOrder { @Column(name = "modified_by") private String modifiedBy; + @Column(name = "manually_entered_by") + private String manuallyEnteredBy; + @UpdateTimestamp @Column(name = "last_mod_date") private Timestamp lastModDate; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java index 6ed0e03fe..abf8475ea 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -14,15 +14,12 @@ public class DiagnosticOrderRequestDto { @NotNull private Long beneficiaryId; - @NotNull - private Long visitCode; - @NotNull private String orderType; private String orderEvent; - private String reasonForRefusal; + private String reasonToClose; @NotNull @Valid diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java index 92eaa03d5..531800f7d 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -13,7 +13,7 @@ public class DiagnosticOrderResultDto { private String providerStatus; private String resultSummary; private String errorMessage; - private String reasonForRefusal; + private String reasonToClose; private Boolean tbPresence; private Double tbConfidence; private Boolean drugResistancePresence; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java index 4e18e0bb1..88eafcab7 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -12,8 +12,7 @@ public class DiagnosticOrderStatusSummaryDto { private List awaitingProviderResult; private List completed; - private List pollingTimedOut; private List failed; - private List refused; + private List closed; private List awaitingManualEntry; } diff --git a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java index 12bd70cdb..cd6a10a83 100644 --- a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java @@ -1,6 +1,5 @@ package com.iemr.flw.dto; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.NoArgsConstructor; @@ -15,6 +14,7 @@ public class ManualDiagnosticResultRequestDto { @NotNull private String orderType; - @NotBlank private String resultSummary; + + private String reasonToClose; } diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java new file mode 100644 index 000000000..f025bacba --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java @@ -0,0 +1,14 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticCancelResult { + private boolean success; + private String rawResponseJson; + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java index 9737cba02..47cd3ee30 100644 --- a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java @@ -11,6 +11,16 @@ public interface DiagnosticProvider { DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception; + /** + * Notifies the vendor that this order is being closed out without (or regardless of) a result, + * so it stops tracking/processing it on their end. Only call for an order actually known to the + * vendor (i.e. previously pushed) — calling this for an order that was never pushed is meaningless. + * The raw response is returned (on both success and a provider-level rejection) so the caller can + * persist it; only a transport-level failure (unreachable, timeout) throws instead, since there's + * no response body to return in that case. + */ + DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception; + /** * Lightweight liveness check against the vendor group serving this orderType. Never throws — * returns false on any failure (unreachable, non-2xx, unparsable response, unconfigured URL). diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java index 0aa20d631..754711ce1 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -4,6 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -60,6 +61,12 @@ public class EmrLiteProvider implements DiagnosticProvider { @Value("${diagnostic.emrlite.truenat.ping-url}") private String truenatPingUrl; + @Value("${diagnostic.emrlite.xray.cancel-url}") + private String xrayCancelUrl; + + @Value("${diagnostic.emrlite.truenat.cancel-url}") + private String truenatCancelUrl; + @Autowired @Qualifier("emrLiteRestTemplate") private RestTemplate restTemplate; @@ -158,6 +165,34 @@ public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAss tbPresence, tbConfidence, drugResistancePresence); } + @Override + public DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception { + EmrLiteCancelRequest request = new EmrLiteCancelRequest(order.getExternalOrderId(), reason); + String cancelUrl = DiagnosticOrderType.XRAY_CHEST.name().equals(order.getOrderType()) + ? xrayCancelUrl : truenatCancelUrl; + String responseBody; + try { + responseBody = doPost(cancelUrl, gson.toJson(request)); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.warn("Provider HTTP error on order cancel: externalOrderId={}, status={}", + order.getExternalOrderId(), e.getStatusCode()); + return new DiagnosticCancelResult(false, body, "HTTP " + e.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (envelope == null || !envelope.isSuccess()) { + logger.warn("Provider rejected order cancel: externalOrderId={}, message={}", + order.getExternalOrderId(), envelope != null ? envelope.getMessage() : null); + return new DiagnosticCancelResult(false, responseBody, + envelope != null ? envelope.getMessage() : "empty response"); + } + + logger.info("Order cancelled on provider: externalOrderId={}, reason={}", + order.getExternalOrderId(), reason); + return new DiagnosticCancelResult(true, responseBody, null); + } + @Override public boolean checkHealth(DiagnosticOrderType orderType) { String pingUrl = DiagnosticOrderType.XRAY_CHEST.equals(orderType) ? xrayPingUrl : truenatPingUrl; @@ -236,8 +271,8 @@ private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { DiagnosticOrderStatus topLevelStatus = DiagnosticOrderStatus.fromString(result.getStatus()); if (topLevelStatus == DiagnosticOrderStatus.COMPLETED || topLevelStatus == DiagnosticOrderStatus.FAILED) { // Components contradict a terminal top-level status — only the components map may - // resolve to a terminal state; treat this as still in progress. - return DiagnosticOrderStatus.IN_PROGRESS; + // resolve to a terminal state; treat this as still pending. + return DiagnosticOrderStatus.PENDING; } return topLevelStatus; } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java new file mode 100644 index 000000000..51f8320da --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteCancelRequest { + private String externalOrderId; + private String reason; +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java index 9a0a8ec23..ce444fcb0 100644 --- a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -5,13 +5,14 @@ public enum DiagnosticOrderStatus { IN_PROGRESS, COMPLETED, FAILED, - CANCELLED, - EXPIRED, - REFUSED, + CLOSED, MANUAL_ENTRY; public static DiagnosticOrderStatus fromString(String value) { if (value == null) return PENDING; + // IN_PROGRESS is kept only as a legacy/historical value (old rows, backward-compat reporting + // queries) — it is never freshly assigned. A vendor reporting "IN_PROGRESS" is just PENDING. + if (IN_PROGRESS.name().equalsIgnoreCase(value)) return PENDING; for (DiagnosticOrderStatus status : values()) { if (status.name().equalsIgnoreCase(value)) { return status; diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index fd7c72892..fa3e79b48 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -16,11 +16,8 @@ public interface DiagnosticOrderRepo extends JpaRepository findByExternalOrderId(String externalOrderId); - @Query("SELECT o FROM DiagnosticOrder o WHERE o.beneficiaryId = :beneficiaryId AND o.visitCode = :visitCode " + - "AND o.orderType = :orderType AND o.deleted = false") - Optional findByBeneficiaryIdAndVisitCodeAndOrderType(@Param("beneficiaryId") Long beneficiaryId, - @Param("visitCode") Long visitCode, - @Param("orderType") String orderType); + Optional findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + Long beneficiaryId, Long visitCode, String orderType); Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( Long beneficiaryId, String orderType); @@ -55,7 +52,7 @@ Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrd // beneficiary with an old terminal order and a new in-flight retest of the same orderType // is bucketed only by the retest, not both. @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status NOT IN ('COMPLETED', 'EXPIRED', 'FAILED', 'REFUSED', 'MANUAL_ENTRY') " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + @@ -74,16 +71,6 @@ List findBeneficiaryIdsAwaitingProviderResult(@Param("orderType") String o List findBeneficiaryIdsCompleted(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'EXPIRED' " + - "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + - "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + - "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + - "AND (:villageId IS NULL OR b.villageID = :villageId) " + - "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsPollingTimedOut(@Param("orderType") String orderType, - @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + "AND o.status = 'FAILED' " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + @@ -95,13 +82,13 @@ List findBeneficiaryIdsFailed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'REFUSED' " + + "AND o.status IN ('CLOSED', 'CANCELLED', 'EXPIRED', 'REFUSED') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsRefused(@Param("orderType") String orderType, + List findBeneficiaryIdsClosed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java index 0bd5207b8..250aa143b 100644 --- a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -12,7 +12,14 @@ public interface DiagnosticOrderService { - DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception; + DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception; + + /** + * Same create/push logic as createAndPushOrder, attributed to "SYSTEM" instead of a JWT-derived + * user. For callers with no authenticated user in context, e.g. the poll scheduler auto-recreating + * an order after an invalid result. + */ + DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto request) throws Exception; DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult result) throws Exception; @@ -22,6 +29,13 @@ public interface DiagnosticOrderService { DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visitCode) throws Exception; + /** + * Best-effort: tells the vendor this order is closed out (no result coming), so they stop + * tracking it. Never throws — a provider failure is logged and swallowed, since the order's own + * CLOSED status is what actually matters locally. No-op if the order was never pushed to a vendor. + */ + void notifyProviderOrderClosed(DiagnosticOrder order, String reason); + DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderType, Long visitCode); DiagnosticOrder getOrder(Long beneficiaryId, String orderType, Long visitCode) throws Exception; @@ -32,5 +46,5 @@ public interface DiagnosticOrderService { VendorHealthDto checkVendorHealth(String orderType) throws Exception; - DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request) throws Exception; + DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request, String jwtToken) throws Exception; } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java index b0f92ab4e..6c0f1f586 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -116,7 +116,7 @@ public DiagnosticDocumentContent fetch(Long beneficiaryId, DiagnosticDocumentTyp String orderType = documentType.impliedOrderType().name(); DiagnosticOrder order; if (visitCode != null) { - order = diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType) + order = diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType) .orElseThrow(() -> new Exception("No diagnostic order found for beneficiaryId=" + beneficiaryId + ", visitCode=" + visitCode + ", orderType=" + orderType)); } else { diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 3e5ca60d6..8aeb045b7 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -1,5 +1,6 @@ package com.iemr.flw.service.impl; +import com.iemr.flw.domain.iemr.BenVisitDetail; import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.domain.iemr.DiagnosticResult; import com.iemr.flw.domain.iemr.TBSuspected; @@ -8,6 +9,7 @@ import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; import com.iemr.flw.dto.ManualDiagnosticResultRequestDto; import com.iemr.flw.dto.VendorHealthDto; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -15,12 +17,17 @@ import com.iemr.flw.integration.provider.DiagnosticPushResult; import com.iemr.flw.masterEnum.DiagnosticOrderStatus; import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.dto.iemr.UserServiceRoleDTO; +import com.iemr.flw.repo.identity.BeneficiaryRepo; import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.repo.iemr.DiagnosticResultRepo; import com.iemr.flw.repo.iemr.TBSuspectedRepo; +import com.iemr.flw.repo.iemr.UserServiceRoleRepo; import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.DiagnosticDocumentService; import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.service.TBStopVisitService; +import com.iemr.flw.utils.JwtUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +38,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; @Service public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @@ -39,10 +47,7 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { private static final Set BLOCKING_STATUSES = Set.of( DiagnosticOrderStatus.PENDING.name(), - DiagnosticOrderStatus.IN_PROGRESS.name(), - DiagnosticOrderStatus.EXPIRED.name(), - DiagnosticOrderStatus.COMPLETED.name(), - DiagnosticOrderStatus.MANUAL_ENTRY.name()); + DiagnosticOrderStatus.COMPLETED.name()); @Autowired private DiagnosticOrderRepo diagnosticOrderRepo; @@ -62,10 +67,29 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @Autowired private CampConfigService campConfigService; + @Autowired + private BeneficiaryRepo beneficiaryRepo; + + @Autowired + private TBStopVisitService tbStopVisitService; + + @Autowired + private JwtUtil jwtUtil; + @Override - public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { + public DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception { + // Username (JWT subject), not the numeric user id — matches modifiedBy/createdBy elsewhere + // in this codebase being a display username, not an id. + return createAndPushOrder(request, jwtUtil.extractUsername(jwtToken)); + } + + @Override + public DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto request) throws Exception { + return createAndPushOrder(request, "SYSTEM"); + } + + private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, String createdBy) throws Exception { Long beneficiaryId = request.getBeneficiaryId(); - Long visitCode = request.getVisitCode(); DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); String orderEvent = request.getOrderEvent(); String patientFirstName = request.getPatient().getFirstName(); @@ -73,24 +97,43 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr String patientDateOfBirth = request.getPatient().getDateOfBirth(); String patientSex = request.getPatient().getSex(); - String providerCode = providerFactory.getProviderCodeForOrderType(orderType); - String externalOrderId = String.format("%d-%d-%s", beneficiaryId, visitCode, orderType.name()); - - String reasonForRefusal = request.getReasonForRefusal(); - if (reasonForRefusal != null) { - return saveRefusedOrder(beneficiaryId, visitCode, orderType, orderEvent, providerCode, externalOrderId, - patientFirstName, patientLastName, patientDateOfBirth, patientSex, reasonForRefusal); + String reasonToClose = request.getReasonToClose(); + if (reasonToClose != null) { + return closeOrder(beneficiaryId, orderType, orderEvent, patientFirstName, patientLastName, + patientDateOfBirth, patientSex, reasonToClose, createdBy); + } + + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); + + Long beneficiaryRegID = beneficiaryRepo.getRegIDFromBenId(beneficiaryId); + if (beneficiaryRegID == null) { + throw new Exception("No beneficiaryRegID found for beneficiaryId=" + beneficiaryId); } + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, null, createdBy, vanID, + parkingPlaceID); + Long visitCode = visit.getVisitCode(); + + String providerCode = providerFactory.getProviderCodeForOrderType(orderType); + String externalOrderId = String.format("%s-%d-%s", UUID.randomUUID(), visitCode, orderType.name()); Optional latestForType = diagnosticOrderRepo .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()); Optional existing = (latestForType.isPresent() && visitCode.equals(latestForType.get().getVisitCode())) ? latestForType - : diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); - - if (existing.isPresent() && !DiagnosticOrderStatus.FAILED.name().equals(existing.get().getStatus())) { - return existing.get(); + : diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); + + if (existing.isPresent()) { + String existingStatus = existing.get().getStatus(); + if (!DiagnosticOrderStatus.FAILED.name().equals(existingStatus) + && !DiagnosticOrderStatus.CLOSED.name().equals(existingStatus)) { + // Still active or already resolved (PENDING/COMPLETED/legacy IN_PROGRESS/MANUAL_ENTRY) + // — returned as-is. + return existing.get(); + } + // FAILED and CLOSED are never reused, even for the same visit — both stay as-is for + // history, and a fresh push always creates a brand new row instead of overwriting either. } if (latestForType.isPresent() @@ -103,11 +146,11 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr return blocker; } - DiagnosticOrder order = existing.orElseGet(DiagnosticOrder::new); - if (order.getVanID() == null) { - order.setVanID(campConfigService.getVanID()); - order.setParkingPlaceID(campConfigService.getParkingPlaceID()); - } + // Neither FAILED nor CLOSED is ever reused above, so this is always a brand new row — a + // beneficiary's diagnostic order history is a sequence of rows, not one row overwritten in place. + DiagnosticOrder order = new DiagnosticOrder(); + order.setVanID(vanID); + order.setParkingPlaceID(parkingPlaceID); order.setOrderEvent(orderEvent); order.setBeneficiaryId(beneficiaryId); order.setVisitCode(visitCode); @@ -115,10 +158,7 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr order.setProviderCode(providerCode); order.setOrderType(orderType.name()); order.setExternalOrderId(externalOrderId); - // Reset required when reusing a previously-FAILED row, otherwise a successful retry would - // leave status=FAILED and findDueForPoll (PENDING/IN_PROGRESS only) would never poll it. order.setStatus(DiagnosticOrderStatus.PENDING.name()); - order.setErrorMessage(null); order.setPatientFirstName(patientFirstName); order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); @@ -128,7 +168,7 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr order = diagnosticOrderRepo.save(order); } catch (DataIntegrityViolationException dive) { Optional winner = diagnosticOrderRepo - .findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); + .findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); if (winner.isPresent()) { logger.warn("Lost create race for beneficiaryId={}, visitCode={}, orderType={} — returning existing order id={}", beneficiaryId, visitCode, orderType, winner.get().getId()); @@ -140,7 +180,7 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr if (providerCode == null || providerCode.isBlank()) { logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry", orderType, beneficiaryId); - order.setStatus(DiagnosticOrderStatus.MANUAL_ENTRY.name()); + // Status stays PENDING (set above) — awaiting manual entry via submitManualResult. order = diagnosticOrderRepo.save(order); if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); return order; @@ -171,16 +211,55 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr return order; } + // Shared by createAndPushOrder (push with reasonToClose) and submitManualResult (manualResult + // with reasonToClose) — resolves the same visit/provider/externalOrderId a normal push would, then + // closes the order via saveRefusedOrder. Identical outcome regardless of which endpoint triggered it. + private DiagnosticOrder closeOrder(Long beneficiaryId, DiagnosticOrderType orderType, String orderEvent, + String patientFirstName, String patientLastName, String patientDateOfBirth, String patientSex, + String reasonToClose, String actingUserId) throws Exception { + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); + + Long beneficiaryRegID = beneficiaryRepo.getRegIDFromBenId(beneficiaryId); + if (beneficiaryRegID == null) { + throw new Exception("No beneficiaryRegID found for beneficiaryId=" + beneficiaryId); + } + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, null, actingUserId, vanID, + parkingPlaceID); + Long visitCode = visit.getVisitCode(); + + String providerCode = providerFactory.getProviderCodeForOrderType(orderType); + String externalOrderId = String.format("%s-%d-%s", UUID.randomUUID(), visitCode, orderType.name()); + return saveRefusedOrder(beneficiaryId, visitCode, orderType, orderEvent, providerCode, externalOrderId, + patientFirstName, patientLastName, patientDateOfBirth, patientSex, reasonToClose, actingUserId); + } + + // manualResult's reasonToClose variant has no patient/orderEvent in its request — it sources them + // from the beneficiary's own most recent order for this orderType (whatever its status), since that + // information already exists there. No prior order at all means there's nothing to source it from. + private DiagnosticOrder closeOrderManually(Long beneficiaryId, DiagnosticOrderType orderType, + String reasonToClose, String actingUserId) throws Exception { + DiagnosticOrder source = diagnosticOrderRepo + .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()) + .orElseThrow(() -> new Exception("No diagnostic order found for beneficiaryId=" + beneficiaryId + + ", orderType=" + orderType.name() + " — cannot close a record that was never created")); + return closeOrder(beneficiaryId, orderType, source.getOrderEvent(), source.getPatientFirstName(), + source.getPatientLastName(), source.getPatientDateOfBirth(), source.getPatientSex(), reasonToClose, + actingUserId); + } + // Refusals are keyed to the latest order for this beneficiary+orderType (not the exact visitCode // of this request), since a refusal can be recorded outside the visit that originally created the - // order. A COMPLETED latest order is left untouched (treated as "not found") and a new REFUSED row - // is created instead. Refused orders are saved as-is and never pushed to the vendor. + // order. A COMPLETED or already-CLOSED latest order is left untouched (treated as "not found") and + // a new CLOSED row is created instead. Refused orders are saved as-is and never pushed to the vendor. private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, DiagnosticOrderType orderType, String orderEvent, String providerCode, String externalOrderId, String patientFirstName, - String patientLastName, String patientDateOfBirth, String patientSex, String reasonForRefusal) { + String patientLastName, String patientDateOfBirth, String patientSex, String reasonToClose, + String actingUserId) { Optional latest = diagnosticOrderRepo .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()); - if (latest.isPresent() && DiagnosticOrderStatus.COMPLETED.name().equals(latest.get().getStatus())) { + if (latest.isPresent() && (DiagnosticOrderStatus.COMPLETED.name().equals(latest.get().getStatus()) + || DiagnosticOrderStatus.CLOSED.name().equals(latest.get().getStatus()))) { latest = Optional.empty(); } @@ -202,17 +281,21 @@ private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, Dia order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); } - order.setStatus(DiagnosticOrderStatus.REFUSED.name()); - order.setReasonForRefusal(reasonForRefusal); + order.setStatus(DiagnosticOrderStatus.CLOSED.name()); + order.setReasonToClose(reasonToClose); order.setErrorMessage(null); + order.setModifiedBy(actingUserId); + order.setManuallyEnteredBy(actingUserId); + order.setProcessed("N"); try { order = diagnosticOrderRepo.save(order); if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); + notifyProviderOrderClosed(order, reasonToClose); return order; } catch (DataIntegrityViolationException dive) { Optional winner = diagnosticOrderRepo - .findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); + .findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); if (winner.isPresent()) { logger.warn("Lost create race for beneficiaryId={}, visitCode={}, orderType={} — returning existing order id={}", beneficiaryId, visitCode, orderType, winner.get().getId()); @@ -222,6 +305,28 @@ private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, Dia } } + @Override + public void notifyProviderOrderClosed(DiagnosticOrder order, String reason) { + String providerCode = order.getProviderCode(); + if (providerCode == null || providerCode.isBlank()) { + return; // no vendor was ever involved (manual-entry order) + } + if (order.getPushResponseJson() == null || order.getPushResponseJson().isEmpty()) { + return; // never actually confirmed as pushed to the vendor — nothing to cancel there + } + try { + DiagnosticCancelResult result = providerFactory.getProvider(providerCode).cancelOrder(order, reason); + order.setCancelResponseJson(result.getRawResponseJson()); + order.setProcessed("N"); + diagnosticOrderRepo.save(order); + } catch (Exception e) { + // Transport-level failure only (unreachable, timeout) — a provider-level rejection is + // already captured in the returned result above, not thrown. + logger.warn("Failed to notify provider of order cancellation for externalOrderId={}: {}", + order.getExternalOrderId(), e.getMessage()); + } + } + @Override public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { Optional existingResult = diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()); @@ -262,6 +367,7 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP order.setProviderOrderId(pollResult.getProviderOrderId()); } order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setProcessed("N"); diagnosticOrderRepo.save(order); if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { @@ -273,7 +379,7 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP dto.setOrderType(order.getOrderType()); dto.setStatus(order.getStatus()); dto.setErrorMessage(order.getErrorMessage()); - dto.setReasonForRefusal(order.getReasonForRefusal()); + dto.setReasonToClose(order.getReasonToClose()); dto.setProviderStatus(result.getProviderStatus()); dto.setResultSummary(result.getResultSummary()); dto.setTbPresence(result.getTbPresence()); @@ -338,6 +444,7 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { order.setStatus(DiagnosticOrderStatus.FAILED.name()); order.setErrorMessage("No push response recorded for this order — cannot poll"); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setProcessed("N"); diagnosticOrderRepo.save(order); return null; } @@ -373,7 +480,7 @@ private DiagnosticOrder resolveOrder(Long beneficiaryId, String orderType, Long return findLatestOrder(beneficiaryId, orderType); } DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); - return diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, type.name()) + return diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, type.name()) .orElseThrow(() -> new Exception("DiagnosticOrder not found for beneficiaryId=" + beneficiaryId + ", visitCode=" + visitCode + ", orderType=" + orderType)); } @@ -392,10 +499,9 @@ public DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visi String status = order.getStatus(); if (DiagnosticOrderStatus.COMPLETED.name().equals(status) - || DiagnosticOrderStatus.CANCELLED.name().equals(status) - || DiagnosticOrderStatus.REFUSED.name().equals(status) - || DiagnosticOrderStatus.MANUAL_ENTRY.name().equals(status)) { - throw new IllegalStateException("Cannot retry polling for order in terminal status " + status); + || DiagnosticOrderStatus.CLOSED.name().equals(status)) { + throw new IllegalStateException("Cannot retry polling for order in terminal status " + status + + " — create a new order instead"); } // retriedAt is kept as an audit timestamp only — the scheduler no longer uses it to anchor a @@ -404,6 +510,7 @@ public DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visi order.setRetriedAt(new Timestamp(System.currentTimeMillis())); order.setStatus(DiagnosticOrderStatus.PENDING.name()); order.setErrorMessage(null); + order.setProcessed("N"); return diagnosticOrderRepo.save(order); } @@ -419,22 +526,18 @@ public List getOrdersByBeneficiaryId(Long beneficiaryId) throws @Override public DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderType, Long visitCode) { - DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); - dto.setOrderType(orderType); - Optional orderOpt = visitCode != null - ? diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType) + ? diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType) : diagnosticOrderRepo.findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType); if (orderOpt.isEmpty()) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setOrderType(orderType); dto.setStatus("NOT_FOUND"); return dto; } DiagnosticOrder order = orderOpt.get(); - dto.setExternalOrderId(order.getExternalOrderId()); - dto.setStatus(order.getStatus()); - dto.setErrorMessage(order.getErrorMessage()); - dto.setReasonForRefusal(order.getReasonForRefusal()); + DiagnosticOrderResultDto dto = toResultDto(order); diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()).ifPresent(result -> { dto.setProviderStatus(result.getProviderStatus()); @@ -446,6 +549,16 @@ public DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderT return dto; } + private DiagnosticOrderResultDto toResultDto(DiagnosticOrder order) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setExternalOrderId(order.getExternalOrderId()); + dto.setOrderType(order.getOrderType()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + dto.setReasonToClose(order.getReasonToClose()); + return dto; + } + @Override public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, Integer providerServiceMapId) { @@ -454,16 +567,13 @@ public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, I .findBeneficiaryIdsAwaitingProviderResult(type.name(), villageId, providerServiceMapId); List completed = diagnosticOrderRepo .findBeneficiaryIdsCompleted(type.name(), villageId, providerServiceMapId); - List pollingTimedOut = diagnosticOrderRepo - .findBeneficiaryIdsPollingTimedOut(type.name(), villageId, providerServiceMapId); List failed = diagnosticOrderRepo .findBeneficiaryIdsFailed(type.name(), villageId, providerServiceMapId); - List refused = diagnosticOrderRepo - .findBeneficiaryIdsRefused(type.name(), villageId, providerServiceMapId); + List closed = diagnosticOrderRepo + .findBeneficiaryIdsClosed(type.name(), villageId, providerServiceMapId); List awaitingManualEntry = diagnosticOrderRepo .findBeneficiaryIdsAwaitingManualEntry(type.name(), villageId, providerServiceMapId); - return new DiagnosticOrderStatusSummaryDto(awaitingProviderResult, completed, pollingTimedOut, failed, refused, - awaitingManualEntry); + return new DiagnosticOrderStatusSummaryDto(awaitingProviderResult, completed, failed, closed, awaitingManualEntry); } @Override @@ -484,12 +594,31 @@ public VendorHealthDto checkVendorHealth(String orderType) throws Exception { } @Override - public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request) throws Exception { + public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request, String jwtToken) + throws Exception { + boolean hasResult = request.getResultSummary() != null && !request.getResultSummary().isBlank(); + boolean hasReasonToClose = request.getReasonToClose() != null && !request.getReasonToClose().isBlank(); + if (hasResult == hasReasonToClose) { + throw new IllegalArgumentException("Exactly one of resultSummary or reasonToClose must be provided"); + } + + // Username (JWT subject), not the numeric user id — matches modifiedBy/createdBy elsewhere + // in this codebase being a display username, not an id. + String actingUserName = jwtUtil.extractUsername(jwtToken); + + if (hasReasonToClose) { + DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); + DiagnosticOrder closed = closeOrderManually(request.getBeneficiaryId(), orderType, + request.getReasonToClose(), actingUserName); + return toResultDto(closed); + } + DiagnosticOrder order = findLatestOrder(request.getBeneficiaryId(), request.getOrderType()); if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { throw new IllegalStateException("Cannot submit manual result: order is already COMPLETED (beneficiaryId=" + request.getBeneficiaryId() + ", orderType=" + request.getOrderType() + ")"); } + order.setManuallyEnteredBy(actingUserName); DiagnosticPollResult pollResult = new DiagnosticPollResult( DiagnosticOrderStatus.COMPLETED, null, request.getResultSummary(), null, null, null, null, null, null); return processResult(order, pollResult); diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java index b21c7095c..2dbb183f9 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -13,14 +13,16 @@ import org.springframework.stereotype.Service; import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; -// Every due order (status PENDING/IN_PROGRESS) is polled on every tick from the moment it's created — +// Every due order (status PENDING) is polled on every tick from the moment it's created — // there is no per-order initial delay or rolling give-up window anymore. A tick is skipped entirely // for an order-type family if diagnostic.provider.xray / diagnostic.provider.truenat has no active // vendor configured right now (checked live via DiagnosticProviderFactory, not any value stored on the -// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING/IN_PROGRESS (for -// order-type families that do have an active vendor) and marks it EXPIRED. +// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING (for +// order-type families that do have an active vendor) and marks it CLOSED. @Service public class DiagnosticPollSchedulerService { @@ -67,7 +69,7 @@ public void pollTrueNatOrders() { logger.info("Polled {} pending TrueNat diagnostic orders", candidates.size()); } - @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 0 18 * * *}") + @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 50 23 * * *}") public void expireOutstandingOrders() { List outstanding = diagnosticOrderRepo.findAllOutstandingOrders(); int expired = 0; @@ -79,7 +81,7 @@ public void expireOutstandingOrders() { expired++; } if (expired > 0) { - logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) EXPIRED", expired); + logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) CLOSED", expired); } } @@ -89,10 +91,13 @@ private boolean noActiveVendor(DiagnosticOrderType orderType) { } private void expire(DiagnosticOrder order) { - order.setStatus(DiagnosticOrderStatus.EXPIRED.name()); - order.setErrorMessage("Daily polling cutoff reached without a result"); + String reason = "Order validity expired for the day " + LocalDate.now(ZoneId.of("Asia/Kolkata")); + order.setStatus(DiagnosticOrderStatus.CLOSED.name()); + order.setReasonToClose(reason); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setProcessed("N"); diagnosticOrderRepo.save(order); + diagnosticOrderService.notifyProviderOrderClosed(order, reason); } private void pollSingle(DiagnosticOrder order) { @@ -105,6 +110,7 @@ private void pollSingle(DiagnosticOrder order) { order.setRetryCount(order.getRetryCount() + 1); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); order.setErrorMessage(e.getMessage()); + order.setProcessed("N"); diagnosticOrderRepo.save(order); } } From e38f64dc3aca01ec95f4e853e9b70bbd8852897c Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 28 Sep 2026 19:25:28 +0530 Subject: [PATCH 2/4] Updated the device integration according to updated workflow --- .../environment/common_example.properties | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 9583947c9..b0238b0c6 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -7,14 +7,14 @@ tm-url=http://localhost:8089/ spring.datasource.url=jdbc:mysql://localhost:3306/db_iemr spring.datasource.username=root -spring.datasource.password=Hrhk@4321 +spring.datasource.password=1234 spring.datasource.driver-class-name=com.mysql.jdbc.Driver ##--------------------------------------------## Secondary db------------------------------------------------------------------- secondary.datasource.url=jdbc:mysql://localhost:3306/db_identity secondary.datasource.username=root -secondary.datasource.password=Hrhk@4321 +secondary.datasource.password=1234 secondary.datasource.driver-class-name=com.mysql.jdbc.Driver springdoc.api-docs.enabled=true @@ -32,7 +32,7 @@ spring.redis.host=localhost # instead of silently storing vanID=NULL (which then never gets synced to central) stoptb.enforce.vanid=false # Stop TB: this deployment's van/camp ID, replacing the old Redis camp:vanID lookup -stoptb.van.id=1 +stoptb.van.id=0 cors.allowed-origins=http://localhost:* @@ -50,45 +50,45 @@ abha.token.url= abha.xcmid= # GovThealth API Config -govthealth.user.details.url=$GOVTHEALTH_USER_DETAILS_URL} -govthealth.user.id=$GOVTHEALTH_USER_ID} -govthealth.password=$GOVTHEALTH_PASSWORD} +govthealth.user.details.url={$GOVTHEALTH_USER_DETAILS_URL} +govthealth.user.id={$GOVTHEALTH_USER_ID} +govthealth.password={$GOVTHEALTH_PASSWORD} # ── Diagnostic Device Integration ──────────────────────────────────────────── # diagnostic.provider.xray / diagnostic.provider.truenat select the vendor per orderType family # (XRAY_CHEST vs MTB/MTB_PLUS/MDR_RIF), each must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode -diagnostic.provider.xray=EMRLITE -diagnostic.provider.truenat=EMRLITE +diagnostic.provider.xray=${DIAGNOSTIC_PROVIDER_XRAY} +diagnostic.provider.truenat=${DIAGNOSTIC_PROVIDER_TRUENAT} # EMR Lite - xray group (same base-url as truenat for now; split so a distinct vendor/tenant can be # swapped in for either group later without a code change) -diagnostic.emrlite.xray.base-url=http://152.67.167.101:8000 +diagnostic.emrlite.xray.base-url=${DIAGNOSTIC_EMRLITE_XRAY_BASE_URL} diagnostic.emrlite.xray.login-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/login/ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ -diagnostic.emrlite.xray.username=piramal -diagnostic.emrlite.xray.password=piramal@123 +diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} +diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} # EMR Lite - truenat group (MTB/MTB_PLUS/MDR_RIF); same value as xray for now -diagnostic.emrlite.truenat.base-url=http://152.67.167.101:8000 +diagnostic.emrlite.truenat.base-url=${DIAGNOSTIC_EMRLITE_TRUENAT_BASE_URL} diagnostic.emrlite.truenat.login-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/login/ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ -diagnostic.emrlite.truenat.username=piramal -diagnostic.emrlite.truenat.password=piramal@123 +diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} +diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} -diagnostic.emrlite.token-ttl-seconds=3600 -diagnostic.emrlite.refresh-ttl-seconds=36000 +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} -diagnostic.poll.xray.tick-ms=30000 -diagnostic.poll.truenat.tick-ms=60000 -# Daily cutoff (cron expression) after which all outstanding PENDING orders are marked CLOSED -diagnostic.poll.expiry-cron=0 50 23 * * * +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} +# Daily cutoff (cron expression) after which all outstanding PENDING/IN_PROGRESS orders are marked EXPIRED +diagnostic.poll.expiry-cron=${DIAGNOSTIC_POLL_EXPIRY_CRON} -diagnostic.documents.storage-root=/home/unthinkable-lap/Desktop/diagnostic-documt +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} From 1e4a9f7236a2b6c15232e9967a4dd384297ab366 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 29 Sep 2026 10:10:24 +0530 Subject: [PATCH 3/4] Updated the device integration according to updated workflow --- src/main/environment/common_example.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index b0238b0c6..a1d4ebe55 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -50,9 +50,9 @@ abha.token.url= abha.xcmid= # GovThealth API Config -govthealth.user.details.url={$GOVTHEALTH_USER_DETAILS_URL} -govthealth.user.id={$GOVTHEALTH_USER_ID} -govthealth.password={$GOVTHEALTH_PASSWORD} +govthealth.user.details.url=${GOVTHEALTH_USER_DETAILS_URL} +govthealth.user.id=${GOVTHEALTH_USER_ID} +govthealth.password=${GOVTHEALTH_PASSWORD} # ── Diagnostic Device Integration ──────────────────────────────────────────── # diagnostic.provider.xray / diagnostic.provider.truenat select the vendor per orderType family From b2923cd8927a0e0add10369a22a90901e85e119f Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 29 Sep 2026 11:15:10 +0530 Subject: [PATCH 4/4] Updated the device integration according to updated workflow --- .../impl/DiagnosticOrderServiceImpl.java | 98 +++++++++++++++---- .../impl/DiagnosticPollSchedulerService.java | 2 + 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 8aeb045b7..12ef9afc6 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -4,6 +4,7 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.domain.iemr.DiagnosticResult; import com.iemr.flw.domain.iemr.TBSuspected; +import com.iemr.flw.domain.iemr.User; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; @@ -21,6 +22,7 @@ import com.iemr.flw.repo.identity.BeneficiaryRepo; import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.repo.iemr.DiagnosticResultRepo; +import com.iemr.flw.repo.iemr.EmployeeMasterRepo; import com.iemr.flw.repo.iemr.TBSuspectedRepo; import com.iemr.flw.repo.iemr.UserServiceRoleRepo; import com.iemr.flw.service.CampConfigService; @@ -47,7 +49,16 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { private static final Set BLOCKING_STATUSES = Set.of( DiagnosticOrderStatus.PENDING.name(), - DiagnosticOrderStatus.COMPLETED.name()); + DiagnosticOrderStatus.COMPLETED.name(), + DiagnosticOrderStatus.MANUAL_ENTRY.name()); + + private static final Set NON_REUSABLE_ON_CLOSE_STATUSES = Set.of( + DiagnosticOrderStatus.COMPLETED.name(), + DiagnosticOrderStatus.FAILED.name(), + DiagnosticOrderStatus.CLOSED.name()); + + private static final String XRAY_INVALID_RESULT = "AI Invalid Result"; + private static final String SPUTUM_INVALID_RESULT = "Invalid/Error"; @Autowired private DiagnosticOrderRepo diagnosticOrderRepo; @@ -76,11 +87,26 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @Autowired private JwtUtil jwtUtil; + @Autowired + private EmployeeMasterRepo employeeMasterRepo; + @Override public DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception { - // Username (JWT subject), not the numeric user id — matches modifiedBy/createdBy elsewhere - // in this codebase being a display username, not an id. - return createAndPushOrder(request, jwtUtil.extractUsername(jwtToken)); + return createAndPushOrder(request, resolveActingUserFirstName(jwtToken)); + } + + // The acting user's m_user.FirstName is what gets stored in createdBy/modifiedBy/manuallyEnteredBy. + // Falls back to the JWT username if the user can't be resolved, so a lookup miss never blocks the flow. + private String resolveActingUserFirstName(String jwtToken) { + try { + User user = employeeMasterRepo.findUserByUserID(jwtUtil.extractUserId(jwtToken)); + if (user != null && user.getFirstName() != null && !user.getFirstName().isBlank()) { + return user.getFirstName(); + } + } catch (Exception e) { + logger.warn("Could not resolve acting user's first name from m_user: {}", e.getMessage()); + } + return jwtUtil.extractUsername(jwtToken); } @Override @@ -158,11 +184,14 @@ private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, St order.setProviderCode(providerCode); order.setOrderType(orderType.name()); order.setExternalOrderId(externalOrderId); - order.setStatus(DiagnosticOrderStatus.PENDING.name()); + boolean noVendor = providerCode == null || providerCode.isBlank(); + order.setStatus(noVendor ? DiagnosticOrderStatus.MANUAL_ENTRY.name() : DiagnosticOrderStatus.PENDING.name()); order.setPatientFirstName(patientFirstName); order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); + order.setCreatedBy(createdBy); + order.setModifiedBy(createdBy); try { order = diagnosticOrderRepo.save(order); @@ -177,10 +206,10 @@ private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, St throw dive; } - if (providerCode == null || providerCode.isBlank()) { + if (noVendor) { logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry", orderType, beneficiaryId); - // Status stays PENDING (set above) — awaiting manual entry via submitManualResult. + // Status is MANUAL_ENTRY (set above) — awaiting manual entry via submitManualResult. order = diagnosticOrderRepo.save(order); if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); return order; @@ -250,16 +279,16 @@ private DiagnosticOrder closeOrderManually(Long beneficiaryId, DiagnosticOrderTy // Refusals are keyed to the latest order for this beneficiary+orderType (not the exact visitCode // of this request), since a refusal can be recorded outside the visit that originally created the - // order. A COMPLETED or already-CLOSED latest order is left untouched (treated as "not found") and - // a new CLOSED row is created instead. Refused orders are saved as-is and never pushed to the vendor. + // order. A COMPLETED, FAILED or already-CLOSED latest order is left untouched (treated as "not found") + // and a new CLOSED row is created instead, so its history (e.g. a FAILED row's errorMessage) survives. + // Refused orders are saved as-is and never pushed to the vendor. private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, DiagnosticOrderType orderType, String orderEvent, String providerCode, String externalOrderId, String patientFirstName, String patientLastName, String patientDateOfBirth, String patientSex, String reasonToClose, String actingUserId) { Optional latest = diagnosticOrderRepo .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()); - if (latest.isPresent() && (DiagnosticOrderStatus.COMPLETED.name().equals(latest.get().getStatus()) - || DiagnosticOrderStatus.CLOSED.name().equals(latest.get().getStatus()))) { + if (latest.isPresent() && NON_REUSABLE_ON_CLOSE_STATUSES.contains(latest.get().getStatus())) { latest = Optional.empty(); } @@ -280,6 +309,7 @@ private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, Dia order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); + order.setCreatedBy(actingUserId); } order.setStatus(DiagnosticOrderStatus.CLOSED.name()); order.setReasonToClose(reasonToClose); @@ -329,6 +359,15 @@ public void notifyProviderOrderClosed(DiagnosticOrder order, String reason) { @Override public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { + return processResult(order, pollResult, false, "SYSTEM"); + } + + // writeBackWhenClosed: a manually submitted invalid result closes the order but its result is + // still written back to tb_suspected, same as a COMPLETED one. + // actingUser: stored as createdBy/modifiedBy — "SYSTEM" for vendor polls, the user's first + // name for a manually submitted result. + private DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult, + boolean writeBackWhenClosed, String actingUser) throws Exception { Optional existingResult = diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()); DiagnosticResult result = existingResult.orElseGet(DiagnosticResult::new); result.setExternalOrderId(order.getExternalOrderId()); @@ -344,7 +383,8 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP result.setTbPresence(pollResult.getTbPresence()); result.setTbConfidence(pollResult.getTbConfidence()); result.setDrugResistancePresence(pollResult.getDrugResistancePresence()); - result.setCreatedBy("SYSTEM"); + result.setCreatedBy(actingUser); + result.setModifiedBy(actingUser); if (result.getVanID() == null) { // Inherit from the parent order rather than re-reading Redis — the result belongs // to whichever van originated the order, not whichever van happens to be polling now. @@ -367,10 +407,12 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP order.setProviderOrderId(pollResult.getProviderOrderId()); } order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setModifiedBy(actingUser); order.setProcessed("N"); diagnosticOrderRepo.save(order); - if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { + if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus()) + || (writeBackWhenClosed && DiagnosticOrderStatus.CLOSED.name().equals(order.getStatus()))) { recordTbSuspectedResult(order, result); } @@ -444,6 +486,7 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { order.setStatus(DiagnosticOrderStatus.FAILED.name()); order.setErrorMessage("No push response recorded for this order — cannot poll"); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setModifiedBy("SYSTEM"); order.setProcessed("N"); diagnosticOrderRepo.save(order); return null; @@ -503,6 +546,10 @@ public DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visi throw new IllegalStateException("Cannot retry polling for order in terminal status " + status + " — create a new order instead"); } + if (DiagnosticOrderStatus.MANUAL_ENTRY.name().equals(status)) { + throw new IllegalStateException("Cannot retry polling for a MANUAL_ENTRY order — no vendor is involved, " + + "submit the result via manualResult instead"); + } // retriedAt is kept as an audit timestamp only — the scheduler no longer uses it to anchor a // poll window; a retried order is simply picked up on the next regular tick like any other @@ -602,9 +649,7 @@ public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequest throw new IllegalArgumentException("Exactly one of resultSummary or reasonToClose must be provided"); } - // Username (JWT subject), not the numeric user id — matches modifiedBy/createdBy elsewhere - // in this codebase being a display username, not an id. - String actingUserName = jwtUtil.extractUsername(jwtToken); + String actingUserName = resolveActingUserFirstName(jwtToken); if (hasReasonToClose) { DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); @@ -619,8 +664,25 @@ public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequest + request.getBeneficiaryId() + ", orderType=" + request.getOrderType() + ")"); } order.setManuallyEnteredBy(actingUserName); + // An invalid test outcome is still recorded exactly like a normal result (result row + + // tb_suspected write-back), but the order is CLOSED rather than COMPLETED. + DiagnosticOrderStatus status = isInvalidResult(order.getOrderType(), request.getResultSummary()) + ? DiagnosticOrderStatus.CLOSED + : DiagnosticOrderStatus.COMPLETED; DiagnosticPollResult pollResult = new DiagnosticPollResult( - DiagnosticOrderStatus.COMPLETED, null, request.getResultSummary(), null, null, null, null, null, null); - return processResult(order, pollResult); + status, null, request.getResultSummary(), null, null, null, null, null, null); + return processResult(order, pollResult, true, actingUserName); + } + + private static boolean isInvalidResult(String orderTypeCode, String resultSummary) { + String summary = resultSummary.trim(); + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderTypeCode); + if (type == DiagnosticOrderType.XRAY_CHEST) { + return XRAY_INVALID_RESULT.equalsIgnoreCase(summary); + } + if (type == DiagnosticOrderType.MTB || type == DiagnosticOrderType.MDR_RIF) { + return SPUTUM_INVALID_RESULT.equalsIgnoreCase(summary); + } + return false; } } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java index 2dbb183f9..2259d934c 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -95,6 +95,7 @@ private void expire(DiagnosticOrder order) { order.setStatus(DiagnosticOrderStatus.CLOSED.name()); order.setReasonToClose(reason); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setModifiedBy("SYSTEM"); order.setProcessed("N"); diagnosticOrderRepo.save(order); diagnosticOrderService.notifyProviderOrderClosed(order, reason); @@ -110,6 +111,7 @@ private void pollSingle(DiagnosticOrder order) { order.setRetryCount(order.getRetryCount() + 1); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); order.setErrorMessage(e.getMessage()); + order.setModifiedBy("SYSTEM"); order.setProcessed("N"); diagnosticOrderRepo.save(order); }