diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 36c26079..191f5401 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -40,3 +40,25 @@ sms-password=@env.SMS_PASSWORD@ send-message-url=@env.SMS_MESSAGE_URL@ #crash.logs.base.path=@env.CRASH_LOGS_PATH@ + +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=@env.DIAGNOSTIC_ACTIVE_PROVIDER@ + +diagnostic.emrlite.base-url=@env.DIAGNOSTIC_EMRLITE_BASE_URL@ +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=@env.DIAGNOSTIC_EMRLITE_USERNAME@ +diagnostic.emrlite.password=@env.DIAGNOSTIC_EMRLITE_PASSWORD@ +diagnostic.emrlite.token-ttl-seconds=@env.DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS@ +diagnostic.emrlite.refresh-ttl-seconds=@env.DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS@ + +diagnostic.poll.xray.tick-ms=@env.DIAGNOSTIC_POLL_XRAY_TICK_MS@ +diagnostic.poll.give-up-minutes=@env.DIAGNOSTIC_POLL_GIVE_UP_MINUTES@ +diagnostic.poll.interval-seconds=@env.DIAGNOSTIC_POLL_INTERVAL_SECONDS@ +diagnostic.poll.truenat.initial-delay-minutes=@env.DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES@ +diagnostic.poll.truenat.tick-ms=@env.DIAGNOSTIC_POLL_TRUENAT_TICK_MS@ + +diagnostic.documents.storage-root=@env.DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 52e09b89..2cb36687 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -40,5 +40,26 @@ send-message-url=${SMS_MESSAGE_URL} #crash.logs.base.path=${CRASH_LOGS_PATH} +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +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=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index cf40a3da..fd6b3620 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -2,7 +2,6 @@ server.port=8081 # FHIR Config fhir-url=http://localhost:8093/ - # TM Config tm-url=http://localhost:8089/ ##--------------------------------------------## Primary db------------------------------------------------------------------- @@ -24,10 +23,8 @@ springdoc.swagger-ui.enabled=true logging.path=logs/ logging.file.name=logs/flw-api.log - jwt.secret=eefa40497bb9b2a8491b85069aff62e7596521c6816e471c464181b52eac7670ff91394a - # Redis Config spring.redis.host=localhost @@ -44,7 +41,24 @@ send-message-url= crash.logs.base.path= - - - - +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +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=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/environment/common_local.properties b/src/main/environment/common_local.properties index f4c0261e..f6b4586d 100644 --- a/src/main/environment/common_local.properties +++ b/src/main/environment/common_local.properties @@ -71,4 +71,28 @@ sms-password= send-message-url= crash.logs.base.path=logs/ -stoptb.enforce.vanid = false \ No newline at end of file +stoptb.enforce.vanid = false + +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +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=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} + + diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java new file mode 100644 index 00000000..c48e257f --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java @@ -0,0 +1,54 @@ +package com.iemr.flw.controller; + +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; +import com.iemr.flw.service.DiagnosticDocumentService; +import io.swagger.v3.oas.annotations.Operation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * SECURITY: this endpoint carries decrypted document bytes and MUST sit behind proper + * authentication/authorization in the real deployment. Today that's this app's existing + * JwtUserIdValidationFilter (utils/JwtUserIdValidationFilter.java), which protects every + * non-whitelisted path by default - do NOT add "/documents" to security.public-paths. + */ +@RestController +@RequestMapping("/documents") +public class DiagnosticDocumentController { + + private final Logger logger = LoggerFactory.getLogger(DiagnosticDocumentController.class); + + private final DiagnosticDocumentService diagnosticDocumentService; + + public DiagnosticDocumentController(DiagnosticDocumentService diagnosticDocumentService) { + this.diagnosticDocumentService = diagnosticDocumentService; + } + + @GetMapping + @Operation(summary = "Fetch the stored document for a beneficiary + document type " + + "(XRAY_CHEST, XRAY_CHEST_ANNOTATED, CAD, MTB_REPORT, MTB_PLUS_REPORT, or MDR_RIF_REPORT). " + + "Defaults to the most recent order's document; pass visitCode to target a specific order/retest.") + public ResponseEntity getDocument(@RequestParam Long benId, @RequestParam DiagnosticDocumentType documentType, + @RequestParam(required = false) Long visitCode) { + try { + DiagnosticDocumentContent content = diagnosticDocumentService.fetch(benId, documentType, visitCode); + MediaType mediaType; + try { + mediaType = MediaType.parseMediaType(content.getContentType()); + } catch (Exception e) { + mediaType = MediaType.APPLICATION_PDF; + } + return ResponseEntity.ok().contentType(mediaType).body(content.getContent()); + } catch (Exception e) { + logger.error("Error fetching document: benId={}, documentType={}: {}", benId, documentType, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } +} diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java new file mode 100644 index 00000000..e300a452 --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -0,0 +1,137 @@ +package com.iemr.flw.controller; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; +import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.utils.ApiResponse; +import com.iemr.flw.utils.response.OutputResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/diagnostic") +public class DiagnosticOrderController { + + private final Logger logger = LoggerFactory.getLogger(DiagnosticOrderController.class); + + private final DiagnosticOrderService diagnosticOrderService; + + public DiagnosticOrderController(DiagnosticOrderService diagnosticOrderService) { + this.diagnosticOrderService = diagnosticOrderService; + } + + @PostMapping("/order/push") + @Operation(summary = "Push a diagnostic order to the configured provider") + public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.createAndPushOrder(request); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in pushOrder: {}", e.getMessage()); + response.setError(5000, "Error pushing diagnostic order: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/get") + @Operation(summary = "Get the diagnostic order for a beneficiary+orderType (latest, if more than one exists; " + + "pass visitCode to target a specific order/retest)") + public String getOrder(@RequestParam Long benRegID, @RequestParam String orderType, + @RequestParam(required = false) Long visitCode) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.getOrder(benRegID, orderType, visitCode); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in getOrder: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic order: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/getByBen/{benRegID}") + @Operation(summary = "Get all diagnostic orders for a beneficiary") + public String getOrdersByBen(@PathVariable Long benRegID) { + OutputResponse response = new OutputResponse(); + try { + List orders = diagnosticOrderService.getOrdersByBenRegId(benRegID); + response.setResponse(new Gson().toJson(orders)); + } catch (Exception e) { + logger.error("Error in getOrdersByBen: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic orders: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/testCompleted") + @Operation(summary = "Mark that the physical test has completed for this beneficiary+orderType, starting the poll cadence " + + "(targets the latest order unless visitCode is given)") + public String markTestCompleted(@RequestParam Long benRegID, @RequestParam String orderType, + @RequestParam(required = false) Long visitCode) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.markTestCompleted(benRegID, orderType, visitCode); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in markTestCompleted: {}", e.getMessage()); + response.setError(5000, "Error marking test completed: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/result") + @Operation(summary = "Get the diagnostic result for benId+orderType, at whatever stage it's currently in " + + "(targets the latest order unless visitCode is given)") + public String getOrderResult(@RequestParam Long benId, @RequestParam String orderType, + @RequestParam(required = false) Long visitCode) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrderResultDto result = diagnosticOrderService.getOrderResult(benId, orderType, visitCode); + response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); + } catch (Exception e) { + logger.error("Error in getOrderResult: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic result: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/poll") + @Operation(summary = "Trigger an immediate poll for one beneficiary+orderType's diagnostic order and return the result (ops use). " + + "Targets the latest order unless visitCode is given") + public String pollOrder(@RequestParam Long benRegID, @RequestParam String orderType, + @RequestParam(required = false) Long visitCode) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrderResultDto result = diagnosticOrderService.triggerManualPoll(benRegID, orderType, visitCode); + response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); + } catch (Exception e) { + logger.error("Error in pollOrder: {}", e.getMessage()); + response.setError(5000, "Error triggering poll: " + e.getMessage()); + } + return response.toString(); + } + + @GetMapping("/order/getBeneficiariesByStatus") + @Operation(summary = "Get beneficiary IDs bucketed by diagnostic order status for the given order type, " + + "optionally filtered by village and/or provider service map") + public ResponseEntity getBeneficiariesByStatus( + @RequestParam DiagnosticOrderType orderType, + @RequestParam(required = false) Integer villageId, + @RequestParam(required = false) Integer providerServiceMapId) { + DiagnosticOrderStatusSummaryDto result = + diagnosticOrderService.getOrderStatusSummary(orderType.name(), villageId, providerServiceMapId); + return ResponseEntity.ok(new ApiResponse(true, "Diagnostic order status summary fetched successfully", result)); + } +} diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java index a0a0adf1..20cd8bfb 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java @@ -337,4 +337,20 @@ public class RMNCHHouseHoldDetails { @Column(name = "gpsLongitude") private Double gpsLongitude; + @Expose + @Column(name = "address") + private String address; + + @Expose + @Column(name = "totalHhMembers") + private Integer totalHhMembers; + + @Expose + @Column(name = "registeredAtCampSite") + private String registeredAtCampSite; + + @Expose + @Column(name = "registeredAtCampSiteId") + private Integer registeredAtCampSiteId; + } diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java new file mode 100644 index 00000000..cb7ce27b --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java @@ -0,0 +1,74 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_document", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"diagnostic_order_id", "document_type"}), + indexes = { + @Index(name = "idx_diagnostic_document_ben_reg_id", columnList = "ben_reg_id"), + @Index(name = "idx_diagnostic_document_order_type", columnList = "order_type"), + @Index(name = "idx_diagnostic_document_epoch_time", columnList = "epoch_time") +}) +@Data +public class DiagnosticDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "diagnostic_order_id") + private Long diagnosticOrderId; + + @Column(name = "ben_reg_id") + private Long benRegID; + + @Column(name = "order_type", length = 20) + private String orderType; + + @Column(name = "asset_type", length = 50) + private String assetType; + + @Column(name = "document_type", length = 30) + private String documentType; + + @Column(name = "epoch_time") + private Long epochTime; + + @Column(name = "stored_file_name", length = 150) + private String storedFileName; + + @Column(name = "stored_path", length = 255) + private String storedPath; + + @Column(name = "sha256_hash", length = 64) + private String sha256Hash; + + @Column(name = "content_type", length = 100) + private String contentType; + + @Column(name = "original_file_name", length = 255) + private String originalFileName; + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java new file mode 100644 index 00000000..5b68328e --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -0,0 +1,103 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_order", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"ben_reg_id", "visitCode", "order_type"})) +@Data +public class DiagnosticOrder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_event", length = 100) + private String orderEvent; + + @Column(name = "ben_reg_id") + private Long benRegID; + + @Column(name = "provider_service_name", length = 50) + private String providerServiceName; + + @Column(name = "provider_code", length = 50) + private String providerCode; + + @Column(name = "order_type", length = 20) + private String orderType; + + @Column(name = "external_order_id", unique = true, length = 100) + private String externalOrderId; + + @Column(name = "provider_order_id", length = 100) + private String providerOrderId; + + @Column(name = "status", length = 20) + private String status; + + @Column(name = "retry_count") + private Integer retryCount = 0; + + @Column(name = "last_polled_at") + private Timestamp lastPolledAt; + + @Column(name = "test_completed_at") + private Timestamp testCompletedAt; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "push_response_json", columnDefinition = "LONGTEXT") + private String pushResponseJson; + + @Column(name = "patient_first_name", length = 100) + private String patientFirstName; + + @Column(name = "patient_last_name", length = 100) + private String patientLastName; + + @Column(name = "patient_date_of_birth", length = 10) + private String patientDateOfBirth; + + @Column(name = "patient_sex", length = 10) + private String patientSex; + + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "visitCode") + private Long visitCode; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java new file mode 100644 index 00000000..906f3a57 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java @@ -0,0 +1,40 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_provider_token", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "token_type"})) +@Data +public class DiagnosticProviderToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "provider_code", length = 50, nullable = false) + private String providerCode; + + // "ACCESS", "REFRESH", "API_KEY" — extensible without schema change + @Column(name = "token_type", length = 20, nullable = false) + private String tokenType; + + @Column(name = "token_value", columnDefinition = "TEXT", nullable = false) + private String tokenValue; + + @Column(name = "expires_at") + private Timestamp expiresAt; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java new file mode 100644 index 00000000..ce292753 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java @@ -0,0 +1,60 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_result", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"diagnostic_order_id"})) +@Data +public class DiagnosticResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "diagnostic_order_id") + private Long diagnosticOrderId; + + @Column(name = "ben_reg_id") + private Long benRegID; + + @Column(name = "provider_status", length = 20) + private String providerStatus; + + @Column(name = "result_summary", columnDefinition = "TEXT") + private String resultSummary; + + @Column(name = "raw_response_json", columnDefinition = "LONGTEXT") + private String rawResponseJson; + + @Column(name = "tb_presence") + private Boolean tbPresence; + + @Column(name = "tb_confidence") + private Double tbConfidence; + + @Column(name = "drug_resistance_presence") + private Boolean drugResistancePresence; + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; +} diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java b/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java new file mode 100644 index 00000000..c508e659 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java @@ -0,0 +1,11 @@ +package com.iemr.flw.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class DiagnosticDocumentContent { + private byte[] content; + private String contentType; +} diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java new file mode 100644 index 00000000..660ad0d5 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -0,0 +1,41 @@ +package com.iemr.flw.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class DiagnosticOrderRequestDto { + + @NotNull + private Long benRegID; + + @NotNull + private Long visitCode; + + @NotNull + private String orderType; + + private String orderEvent; + + @NotNull + @Valid + private PatientDto patient; + + @Data + @NoArgsConstructor + public static class PatientDto { + @NotBlank private String firstName; + @NotBlank private String lastName; + @NotBlank + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "dateOfBirth must be in yyyy-MM-dd format") + private String dateOfBirth; + @NotBlank + @Pattern(regexp = "Male|Female|Others", message = "sex must be exactly one of Male, Female, Others") + private String sex; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java new file mode 100644 index 00000000..25b3d658 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -0,0 +1,19 @@ +package com.iemr.flw.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class DiagnosticOrderResultDto { + + private String externalOrderId; + private String orderType; + private String status; + private String providerStatus; + private String resultSummary; + private String errorMessage; + private Boolean tbPresence; + private Double tbConfidence; + private Boolean drugResistancePresence; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java new file mode 100644 index 00000000..c1dd235e --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -0,0 +1,18 @@ +package com.iemr.flw.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticOrderStatusSummaryDto { + private List awaitingTestCompletion; + private List awaitingProviderResult; + private List completed; + private List pollingTimedOut; + private List failed; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java new file mode 100644 index 00000000..a43d1d55 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java @@ -0,0 +1,21 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Source-agnostic document payload accepted by DiagnosticDocumentService.ingestAsset(...). + * Not tied to any specific provider's DTO shape (e.g. EMR Lite's AssetDto) or to any + * particular polling flow — deliberately decoupled so it can be fed from wherever the + * caller ends up being wired in. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticDocumentAsset { + private String type; + private String contentType; + private String fileName; + private String base64Content; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java new file mode 100644 index 00000000..9f652bf6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java @@ -0,0 +1,23 @@ +package com.iemr.flw.integration.provider; + +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticPollResult { + private DiagnosticOrderStatus status; + private String providerOrderId; + private String resultSummary; + private String rawResponseJson; + private List assets; + private String errorMessage; + private Boolean tbPresence; + private Double tbConfidence; + private Boolean drugResistancePresence; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java new file mode 100644 index 00000000..86bf1cbc --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java @@ -0,0 +1,12 @@ +package com.iemr.flw.integration.provider; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; + +public interface DiagnosticProvider { + + String getProviderCode(); + + DiagnosticPushResult pushOrder(DiagnosticOrder order) throws Exception; + + DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java new file mode 100644 index 00000000..0c92c5bb --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java @@ -0,0 +1,32 @@ +package com.iemr.flw.integration.provider; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class DiagnosticProviderFactory { + + @Autowired + private List providers; + + private Map registry; + + @PostConstruct + public void init() { + registry = providers.stream() + .collect(Collectors.toMap(DiagnosticProvider::getProviderCode, p -> p)); + } + + public DiagnosticProvider getProvider(String providerCode) { + DiagnosticProvider provider = registry.get(providerCode); + if (provider == null) { + throw new IllegalArgumentException("No diagnostic provider registered for code: " + providerCode); + } + return provider; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java new file mode 100644 index 00000000..6920b87d --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java @@ -0,0 +1,15 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticPushResult { + private boolean success; + private String providerOrderId; + private String rawResponseJson; + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java new file mode 100644 index 00000000..eb2e7c36 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java @@ -0,0 +1,64 @@ +package com.iemr.flw.integration.provider.emrlite; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** + * Attaches the current EMR Lite bearer token to every outgoing request and, on a 401, + * forces a token refresh and retries once. Scoped to the emrLiteRestTemplate bean only — + * must not be reused for other providers' RestTemplate instances. + */ +public class EmrLiteAuthInterceptor implements ClientHttpRequestInterceptor { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteAuthInterceptor.class); + + private final EmrLiteTokenManager tokenManager; + + public EmrLiteAuthInterceptor(EmrLiteTokenManager tokenManager) { + this.tokenManager = tokenManager; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) + throws IOException { + request.getHeaders().setBearerAuth(getValidToken()); + ClientHttpResponse response = execution.execute(request, body); + if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) { + logger.warn("Received 401 from EMR Lite, foremrLiteRestTemplatecing token refresh and retrying"); + response.close(); + request.getHeaders().setBearerAuth(forceRefresh()); + return execution.execute(request, body); + } + return response; + } + + private String getValidToken() throws IOException { + try { + return tokenManager.getValidToken(); + } catch (Exception e) { + // Logged here (with full stack trace) because every current caller up the chain + // (DiagnosticOrderServiceImpl, DiagnosticPollSchedulerService) only logs e.getMessage(), + // which would otherwise show just this method's generic wrapper text. Folding the real + // cause into the message too means those shallow logs become useful without having to + // touch every call site. + logger.error("Unable to obtain EMR Lite token", e); + throw new IOException("Unable to obtain EMR Lite token: " + e.getMessage(), e); + } + } + + private String forceRefresh() throws IOException { + try { + return tokenManager.forceRefresh(); + } catch (Exception e) { + logger.error("Unable to refresh EMR Lite token", e); + throw new IOException("Unable to refresh EMR Lite token: " + e.getMessage(), e); + } + } +} 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 new file mode 100644 index 00000000..1c1c776d --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -0,0 +1,279 @@ +package com.iemr.flw.integration.provider.emrlite; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.integration.provider.DiagnosticProvider; +import com.iemr.flw.integration.provider.DiagnosticPushResult; +import com.iemr.flw.integration.provider.emrlite.dto.*; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.masterEnum.DiagnosticProviderCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class EmrLiteProvider implements DiagnosticProvider { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteProvider.class); + private static final String PROVIDER_CODE = DiagnosticProviderCode.EMRLITE.name(); + private static final String CAD_COMPONENT_KEY = "cad"; + private static final String TUBERCULOSIS_FINDING_NAME = "Tuberculosis"; + + private static final String SUMMARY_TB_POSITIVE = "TB Positive"; + private static final String SUMMARY_TB_NEGATIVE = "TB Negative"; + private static final String SUMMARY_DR_TB = "DR TB"; + private static final String SUMMARY_NON_DR_TB = "Non DR TB"; + + @Value("${diagnostic.emrlite.order-url}") + private String orderUrl; + + @Value("${diagnostic.emrlite.result-url}") + private String resultUrl; + + @Autowired + @Qualifier("emrLiteRestTemplate") + private RestTemplate restTemplate; + + private final Gson gson = new Gson(); + + @Override + public String getProviderCode() { + return PROVIDER_CODE; + } + + @Override + public DiagnosticPushResult pushOrder(DiagnosticOrder order) throws Exception { + EmrLiteOrderRequest request = buildOrderRequest(order); + String responseBody; + try { + responseBody = doPost(orderUrl, gson.toJson(request)); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + String providerMessage = null; + String errorDetail = ""; + try { + EmrLiteProviderResponse errorEnvelope = gson.fromJson(body, EmrLiteProviderResponse.class); + if (errorEnvelope != null) { + providerMessage = errorEnvelope.getMessage(); + errorDetail = describeErrorData(errorEnvelope.getData()); + } + } catch (Exception parseEx) { + logger.debug("Could not parse provider error body as envelope JSON for externalOrderId={}", + order.getExternalOrderId()); + } + String errorMessage = (providerMessage != null && !providerMessage.isBlank()) + ? "HTTP " + e.getStatusCode() + ": " + providerMessage + errorDetail + : "HTTP " + e.getStatusCode() + errorDetail; + logger.warn("Provider HTTP error on order push: externalOrderId={}, status={}, message={}", + order.getExternalOrderId(), e.getStatusCode(), providerMessage); + return new DiagnosticPushResult(false, null, body, errorMessage); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (!envelope.isSuccess()) { + logger.warn("Provider rejected order push: externalOrderId={}, message={}", + order.getExternalOrderId(), envelope.getMessage()); + return new DiagnosticPushResult(false, null, responseBody, envelope.getMessage()); + } + + EmrLiteOrderResponse orderResponse = gson.fromJson(envelope.getData(), EmrLiteOrderResponse.class); + logger.info("Order pushed: externalOrderId={}, status={}", + order.getExternalOrderId(), orderResponse.getStatus()); + return new DiagnosticPushResult(true, orderResponse.getExternalOrderId(), responseBody, null); + } + + @Override + public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception { + EmrLiteResultRequest request = new EmrLiteResultRequest(order.getExternalOrderId(), includeAssets); + String responseBody; + try { + responseBody = doPost(resultUrl, gson.toJson(request)); + } catch (HttpClientErrorException.NotFound e) { + // Permanent per the API contract — return FAILED instead of throwing, so the scheduler + // stops instead of retrying on the transient back-off schedule. + logger.warn("Provider returned 404 Not Found for externalOrderId={}", order.getExternalOrderId()); + return new DiagnosticPollResult(DiagnosticOrderStatus.FAILED, null, null, + e.getResponseBodyAsString(), null, + "Order not found on provider (404): externalOrderId=" + order.getExternalOrderId(), + false, null, false); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (!envelope.isSuccess()) { + throw new Exception("Provider rejected result poll: " + envelope.getMessage()); + } + + EmrLiteResultResponse result = gson.fromJson(envelope.getData(), EmrLiteResultResponse.class); + DiagnosticOrderStatus status = deriveOrderStatus(result); + + String summary = result.getResult() != null ? result.getResult().getSummary() : null; + List assets = mapAssets(result); + EmrLiteCadRawJson.FindingDto tbFinding = extractTuberculosisFinding(result); + Boolean tbPresence = tbFinding != null ? tbFinding.isPresence() : null; + Double tbConfidence = tbFinding != null ? tbFinding.getConfidence() : null; + if (tbPresence == null) { + tbPresence = classifyTbPresenceFromSummary(order.getOrderType(), summary); + } + Boolean drugResistancePresence = classifyDrugResistanceFromSummary(order.getOrderType(), summary); + + return new DiagnosticPollResult(status, order.getExternalOrderId(), summary, responseBody, assets, null, + tbPresence, tbConfidence, drugResistancePresence); + } + + private Boolean classifyTbPresenceFromSummary(String orderType, String summary) { + if (!DiagnosticOrderType.MTB.name().equals(orderType) && !DiagnosticOrderType.MTB_PLUS.name().equals(orderType)) { + return null; // TB presence isn't tested for this orderType — not applicable, not a confirmed negative + } + if (SUMMARY_TB_POSITIVE.equalsIgnoreCase(summary)) { + return true; + } + if (SUMMARY_TB_NEGATIVE.equalsIgnoreCase(summary)) { + return false; + } + return null; // Error/Indeterminate/anything else — genuinely unknown, not a confirmed negative + } + + // MDR_RIF's summary reports rifampicin drug-resistance, not raw TB presence — kept as its own + // field rather than folded into tbPresence. + private Boolean classifyDrugResistanceFromSummary(String orderType, String summary) { + if (!DiagnosticOrderType.MDR_RIF.name().equals(orderType)) { + return null; // Drug resistance isn't tested for this orderType — not applicable, not a confirmed negative + } + if (SUMMARY_DR_TB.equalsIgnoreCase(summary)) { + return true; + } + if (SUMMARY_NON_DR_TB.equalsIgnoreCase(summary)) { + return false; + } + return null; // Error/Indeterminate/anything else — genuinely unknown, not a confirmed negative + } + + private EmrLiteCadRawJson.FindingDto extractTuberculosisFinding(EmrLiteResultResponse result) { + if (result.getComponents() == null || !result.getComponents().containsKey(CAD_COMPONENT_KEY)) { + return null; + } + if (result.getResult() == null || result.getResult().getRawJson() == null) { + return null; + } + try { + EmrLiteCadRawJson cadRawJson = + gson.fromJson(gson.toJson(result.getResult().getRawJson()), EmrLiteCadRawJson.class); + if (cadRawJson.getResults() == null || cadRawJson.getResults().getFindings() == null) { + return null; + } + return cadRawJson.getResults().getFindings().stream() + .filter(f -> TUBERCULOSIS_FINDING_NAME.equalsIgnoreCase(f.getName())) + .findFirst() + .orElse(null); + } catch (Exception e) { + logger.warn("Failed to parse CAD rawJson findings for externalOrderId={}: {}", + result.getExternalOrderId(), e.getMessage()); + return null; + } + } + + // Provider's top-level "status" is unreliable on its own (can report COMPLETED while a + // component is still PENDING), so true completion is derived from the components map instead. + private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { + Map components = result.getComponents(); + if (components == null || components.isEmpty()) { + return DiagnosticOrderStatus.fromString(result.getStatus()); + } + boolean anyFailed = components.values().stream() + .anyMatch(c -> "FAILED".equalsIgnoreCase(c.getStatus())); + if (anyFailed) { + return DiagnosticOrderStatus.FAILED; + } + boolean allCompleted = components.values().stream() + .allMatch(c -> "COMPLETED".equalsIgnoreCase(c.getStatus())); + if (allCompleted) { + return DiagnosticOrderStatus.COMPLETED; + } + 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; + } + return topLevelStatus; + } + + private String describeErrorData(JsonElement data) { + if (data == null || !data.isJsonObject()) { + return ""; + } + JsonObject dataObject = data.getAsJsonObject(); + JsonElement code = dataObject.get("code"); + JsonElement field = dataObject.get("field"); + if (code == null && field == null) { + return ""; + } + StringBuilder detail = new StringBuilder(" ("); + if (code != null) { + detail.append("code=").append(code.getAsString()); + } + if (field != null) { + if (code != null) { + detail.append(", "); + } + detail.append("field=").append(field.getAsString()); + } + return detail.append(")").toString(); + } + + private List mapAssets(EmrLiteResultResponse result) { + if (result.getResult() == null || result.getResult().getAssets() == null) { + return new ArrayList<>(); + } + List assets = new ArrayList<>(); + for (EmrLiteResultResponse.AssetDto assetDto : result.getResult().getAssets()) { + assets.add(new DiagnosticDocumentAsset( + assetDto.getType(), assetDto.getContentType(), assetDto.getFileName(), assetDto.getBase64())); + } + return assets; + } + + private String doPost(String url, String jsonBody) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + return response.getBody(); + } + + private EmrLiteOrderRequest buildOrderRequest(DiagnosticOrder order) { + EmrLiteOrderRequest.PatientDto patient = new EmrLiteOrderRequest.PatientDto( + order.getPatientFirstName(), + order.getPatientLastName(), + order.getPatientDateOfBirth(), + order.getPatientSex()); + String orderedAt = OffsetDateTime.now(ZoneId.of("Asia/Kolkata")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + return new EmrLiteOrderRequest( + String.valueOf(order.getBenRegID()), + String.valueOf(order.getVisitCode()), + order.getExternalOrderId(), + order.getOrderType(), + orderedAt, + patient); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java new file mode 100644 index 00000000..cbc9993c --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java @@ -0,0 +1,21 @@ +package com.iemr.flw.integration.provider.emrlite; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +/** + * Dedicated RestTemplate for EMR Lite diagnostic provider calls only. + * Do NOT reuse this bean for other integrations (TM API, FHIR, etc.) — EmrLiteAuthInterceptor + * unconditionally attaches an EMR Lite bearer token to every request that goes through it. + */ +@Configuration +public class EmrLiteRestTemplateConfig { + + @Bean + public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new EmrLiteAuthInterceptor(emrLiteTokenManager)); + return restTemplate; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java new file mode 100644 index 00000000..2b0b3dd6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java @@ -0,0 +1,241 @@ +package com.iemr.flw.integration.provider.emrlite; + +import com.google.gson.Gson; +import com.iemr.flw.domain.iemr.DiagnosticProviderToken; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteLoginRequest; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteLoginResponse; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteProviderResponse; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteRefreshRequest; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteRefreshResponse; +import com.iemr.flw.masterEnum.DiagnosticProviderCode; +import com.iemr.flw.repo.iemr.DiagnosticProviderTokenRepo; +import com.iemr.flw.utils.CryptoUtil; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Optional; + +@Component +public class EmrLiteTokenManager { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteTokenManager.class); + private static final String PROVIDER_CODE = DiagnosticProviderCode.EMRLITE.name(); + private static final String TYPE_ACCESS = "ACCESS"; + private static final String TYPE_REFRESH = "REFRESH"; + + @Value("${diagnostic.emrlite.login-url}") + private String loginUrl; + + @Value("${diagnostic.emrlite.refresh-url}") + private String refreshUrl; + + @Value("${diagnostic.emrlite.username}") + private String username; + + @Value("${diagnostic.emrlite.password}") + private String encodedPassword; + + @Value("${diagnostic.emrlite.token-ttl-seconds:3600}") + private long tokenTtlSeconds; + + @Value("${diagnostic.emrlite.refresh-ttl-seconds:82800}") + private long refreshTtlSeconds; + + private String password; + + @Autowired + private DiagnosticProviderTokenRepo tokenRepo; + + @Autowired + private CryptoUtil cryptoUtil; + + private final RestTemplate restTemplate = new RestTemplate(); + private final Gson gson = new Gson(); + + @PostConstruct + private void init() { + this.password = encodedPassword != null && encodedPassword.startsWith("0X10:") + ? new String(Base64.getDecoder().decode(encodedPassword.substring(5))) + : encodedPassword; + } + + public String getValidToken() throws Exception { + Optional record = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_ACCESS); + if (record.isPresent()) { + DiagnosticProviderToken token = record.get(); + boolean expired = token.getExpiresAt() == null + || Instant.now().isAfter(token.getExpiresAt().toInstant().minusSeconds(60)); + if (!expired) { + return cryptoUtil.decrypt(token.getTokenValue()); + } + } + synchronized (this) { + Optional recheck = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_ACCESS); + if (recheck.isPresent()) { + DiagnosticProviderToken t = recheck.get(); + boolean stillExpired = t.getExpiresAt() == null + || Instant.now().isAfter(t.getExpiresAt().toInstant().minusSeconds(60)); + if (!stillExpired) { + return cryptoUtil.decrypt(t.getTokenValue()); + } + } + return refreshOrLogin(); + } + } + + public synchronized String forceRefresh() throws Exception { + return refreshOrLogin(); + } + + private String refreshOrLogin() throws Exception { + try { + return refreshAccessToken(); + } catch (Exception e) { + logger.warn("Token refresh failed ({}), falling back to full login", e.getMessage()); + return login(); + } + } + + private String refreshAccessToken() throws Exception { + Optional refreshRecord = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_REFRESH); + if (refreshRecord.isEmpty()) { + throw new Exception("No refresh token in DB — need full login"); + } + DiagnosticProviderToken refreshToken = refreshRecord.get(); + boolean refreshExpired = refreshToken.getExpiresAt() == null + || Instant.now().isAfter(refreshToken.getExpiresAt().toInstant().minusSeconds(60)); + if (refreshExpired) { + throw new Exception("Refresh token expired — need full login"); + } + String rawRefresh = cryptoUtil.decrypt(refreshToken.getTokenValue()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(gson.toJson(new EmrLiteRefreshRequest(rawRefresh)), headers); + + ResponseEntity response; + try { + response = restTemplate.exchange(refreshUrl, HttpMethod.POST, entity, String.class); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.warn("EMR Lite refresh rejected by provider: url={}, status={}, body={}", + refreshUrl, e.getStatusCode(), body); + throw new Exception("Refresh token rejected by provider (HTTP " + e.getStatusCode() + ") - " + body, e); + } catch (RestClientException e) { + logger.warn("EMR Lite refresh request failed (network/connection error): url={}", refreshUrl, e); + throw new Exception("EMR Lite refresh request failed: " + e.getMessage(), e); + } + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + logger.warn("EMR Lite refresh failed: url={}, status={}, body={}", + refreshUrl, response.getStatusCode(), response.getBody()); + throw new Exception("Token refresh failed with status: " + response.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(response.getBody(), EmrLiteProviderResponse.class); + if (envelope == null) { + logger.warn("EMR Lite refresh response could not be parsed: url={}, rawBody={}", refreshUrl, response.getBody()); + throw new Exception("EMR Lite refresh response could not be parsed, rawBody=" + response.getBody()); + } + if (!envelope.isSuccess()) { + logger.warn("EMR Lite refresh rejected: url={}, message={}, rawBody={}", + refreshUrl, envelope.getMessage(), response.getBody()); + throw new Exception("Token refresh rejected: " + envelope.getMessage()); + } + + EmrLiteRefreshResponse refreshResponse = gson.fromJson(envelope.getData(), EmrLiteRefreshResponse.class); + if (refreshResponse.getAccess() == null) { + throw new Exception("Refresh response returned null access token"); + } + + persistToken(TYPE_ACCESS, refreshResponse.getAccess(), + Timestamp.from(Instant.now().plus(tokenTtlSeconds, ChronoUnit.SECONDS))); + + logger.info("EMR Lite access token refreshed, TTL {}s", tokenTtlSeconds); + return refreshResponse.getAccess(); + } + + private String login() throws Exception { + logger.info("Attempting EMR Lite login: url={}, username={}", loginUrl, username); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>( + gson.toJson(new EmrLiteLoginRequest(username, password)), headers); + + ResponseEntity response; + try { + response = restTemplate.exchange(loginUrl, HttpMethod.POST, entity, String.class); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.error("EMR Lite login rejected by provider: url={}, status={}, body={}", + loginUrl, e.getStatusCode(), body); + throw new Exception("EMR Lite login failed: HTTP " + e.getStatusCode() + " - " + body, e); + } catch (RestClientException e) { + logger.error("EMR Lite login request failed (network/connection error): url={}", loginUrl, e); + throw new Exception("EMR Lite login request failed: " + e.getMessage(), e); + } + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + logger.error("EMR Lite login failed: url={}, status={}, body={}", + loginUrl, response.getStatusCode(), response.getBody()); + throw new Exception("EMR Lite login failed with status: " + response.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(response.getBody(), EmrLiteProviderResponse.class); + if (envelope == null) { + logger.error("EMR Lite login response could not be parsed: url={}, rawBody={}", loginUrl, response.getBody()); + throw new Exception("EMR Lite login response could not be parsed, rawBody=" + response.getBody()); + } + if (!envelope.isSuccess()) { + logger.error("EMR Lite login rejected: url={}, message={}, rawBody={}", + loginUrl, envelope.getMessage(), response.getBody()); + throw new Exception("EMR Lite login rejected: " + envelope.getMessage()); + } + + EmrLiteLoginResponse tokens = gson.fromJson(envelope.getData(), EmrLiteLoginResponse.class); + if (tokens == null || tokens.getAccessToken() == null) { + logger.error("EMR Lite login returned no accessToken: url={}, rawBody={}", loginUrl, response.getBody()); + throw new Exception("Login returned null accessToken"); + } + + long accessTtlSeconds = tokens.getExpiresIn() != null ? tokens.getExpiresIn() : tokenTtlSeconds; + persistToken(TYPE_ACCESS, tokens.getAccessToken(), + Timestamp.from(Instant.now().plus(accessTtlSeconds, ChronoUnit.SECONDS))); + if (tokens.getRefreshToken() != null) { + persistToken(TYPE_REFRESH, tokens.getRefreshToken(), + Timestamp.from(Instant.now().plus(refreshTtlSeconds, ChronoUnit.SECONDS))); + } + + logger.info("EMR Lite login successful, access TTL {}s (from {}), refresh TTL {}s", accessTtlSeconds, + tokens.getExpiresIn() != null ? "provider" : "config", refreshTtlSeconds); + return tokens.getAccessToken(); + } + + private void persistToken(String tokenType, String rawValue, Timestamp expiresAt) throws Exception { + String encrypted = cryptoUtil.encrypt(rawValue); + Optional existing = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, tokenType); + DiagnosticProviderToken record = existing.orElseGet(DiagnosticProviderToken::new); + record.setProviderCode(PROVIDER_CODE); + record.setTokenType(tokenType); + record.setTokenValue(encrypted); + record.setExpiresAt(expiresAt); + tokenRepo.save(record); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java new file mode 100644 index 00000000..a8e98754 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java @@ -0,0 +1,33 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Typed view over EmrLiteResultResponse.ResultDto.rawJson, used only to pull the CAD + * (e.g. DRONGOAI) findings list back out of that otherwise-opaque blob. Only the fields + * needed for that extraction are declared - unmapped JSON fields (image, metadata, ...) + * are ignored by Gson rather than modeled here. + */ +@Data +@NoArgsConstructor +public class EmrLiteCadRawJson { + + private ResultsDto results; + + @Data + @NoArgsConstructor + public static class ResultsDto { + private List findings; + } + + @Data + @NoArgsConstructor + public static class FindingDto { + private String name; + private boolean presence; + private double confidence; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java new file mode 100644 index 00000000..35aa37d8 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.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 EmrLiteLoginRequest { + private String username; + private String password; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java new file mode 100644 index 00000000..c36e519b --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteLoginResponse { + private String accessToken; + private String refreshToken; + private String tokenType; + private Integer expiresIn; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java new file mode 100644 index 00000000..e3b6ae9b --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java @@ -0,0 +1,28 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteOrderRequest { + + private String externalPatientId; + private String externalVisitId; + private String externalOrderId; + private String orderType; + private String orderedAt; + private PatientDto patient; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class PatientDto { + private String firstName; + private String lastName; + private String dateOfBirth; + private String sex; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java new file mode 100644 index 00000000..4a791c38 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java @@ -0,0 +1,14 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteOrderResponse { + private String externalPatientId; + private String externalVisitId; + private String externalOrderId; + private String orderType; + private String status; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java new file mode 100644 index 00000000..0c34267f --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java @@ -0,0 +1,19 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import com.google.gson.JsonElement; +import lombok.Data; +import lombok.NoArgsConstructor; + +// Data is JsonElement, not JsonObject, since the API sometimes returns it as a plain string +// (e.g. error cases) instead of an object. +@Data +@NoArgsConstructor +public class EmrLiteProviderResponse { + private String Result; + private JsonElement Data; + private String Message; + + public boolean isSuccess() { + return "Success".equalsIgnoreCase(Result); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java new file mode 100644 index 00000000..47fb32c6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java @@ -0,0 +1,12 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteRefreshRequest { + private String refresh; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java new file mode 100644 index 00000000..3a69510e --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java @@ -0,0 +1,11 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteRefreshResponse { + // Field name is "access" (not "accessToken") per the getAccessToken API contract + private String access; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java new file mode 100644 index 00000000..a7dcd706 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java @@ -0,0 +1,19 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteResultRequest { + private String externalOrderId; + // "base64" includes report/image file content in the response's assets array; "none" returns + // status/summary only. Callers pass true only for the one follow-up call made once an order + // resolves COMPLETED - every other poll is a cheap status-only check. + private String includeAssets; + + public EmrLiteResultRequest(String externalOrderId, boolean includeAssets) { + this.externalOrderId = externalOrderId; + this.includeAssets = includeAssets ? "base64" : "none"; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java new file mode 100644 index 00000000..a9b33bb6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java @@ -0,0 +1,43 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +public class EmrLiteResultResponse { + + private String externalOrderId; + private String orderType; + private String status; + private Map components; + private ResultDto result; + + @Data + @NoArgsConstructor + public static class ComponentStatus { + private String status; + private String provider; + } + + @Data + @NoArgsConstructor + public static class ResultDto { + private String summary; + private Object rawJson; + private String reportedAt; + private List assets; + } + + @Data + @NoArgsConstructor + public static class AssetDto { + private String type; + private String contentType; + private String fileName; + private String base64; + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java new file mode 100644 index 00000000..3e26e5e5 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java @@ -0,0 +1,51 @@ +package com.iemr.flw.masterEnum; + +// XRAY_CHEST orders can carry up to three distinct artifacts (raw capture, AI-annotated capture, +// CAD report), each needing its own value under the (benRegID, documentType) storage key. +public enum DiagnosticDocumentType { + XRAY_CHEST, + XRAY_CHEST_ANNOTATED, + CAD, + MTB_REPORT, + MTB_PLUS_REPORT, + MDR_RIF_REPORT; + + private static final String REPORT_ASSET_TYPE = "REPORT"; + private static final String SECONDARY_CAPTURE_ASSET_TYPE = "SECONDARY_CAPTURE"; + + public static DiagnosticDocumentType from(String orderType, String assetType) { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + if (type == DiagnosticOrderType.XRAY_CHEST) { + if (REPORT_ASSET_TYPE.equalsIgnoreCase(assetType)) { + return CAD; + } + if (SECONDARY_CAPTURE_ASSET_TYPE.equalsIgnoreCase(assetType)) { + return XRAY_CHEST_ANNOTATED; + } + return XRAY_CHEST; + } + switch (type) { + case MTB: return MTB_REPORT; + case MTB_PLUS: return MTB_PLUS_REPORT; + case MDR_RIF: return MDR_RIF_REPORT; + default: throw new IllegalArgumentException("Unknown orderType for document classification: " + orderType); + } + } + + public DiagnosticOrderType impliedOrderType() { + switch (this) { + case XRAY_CHEST: + case XRAY_CHEST_ANNOTATED: + case CAD: + return DiagnosticOrderType.XRAY_CHEST; + case MTB_REPORT: + return DiagnosticOrderType.MTB; + case MTB_PLUS_REPORT: + return DiagnosticOrderType.MTB_PLUS; + case MDR_RIF_REPORT: + return DiagnosticOrderType.MDR_RIF; + default: + throw new IllegalArgumentException("Unknown orderType for document type: " + this); + } + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java new file mode 100644 index 00000000..75cb0e5e --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -0,0 +1,20 @@ +package com.iemr.flw.masterEnum; + +public enum DiagnosticOrderStatus { + PENDING, + IN_PROGRESS, + COMPLETED, + FAILED, + CANCELLED, + EXPIRED; + + public static DiagnosticOrderStatus fromString(String value) { + if (value == null) return PENDING; + for (DiagnosticOrderStatus status : values()) { + if (status.name().equalsIgnoreCase(value)) { + return status; + } + } + return PENDING; + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java new file mode 100644 index 00000000..46922326 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java @@ -0,0 +1,40 @@ +package com.iemr.flw.masterEnum; + +public enum DiagnosticOrderType { + XRAY_CHEST(1), + MTB(2), + MTB_PLUS(3), + MDR_RIF(4); + + private final int id; + + DiagnosticOrderType(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public String getCode() { + return this.name(); + } + + public static DiagnosticOrderType fromId(int id) { + for (DiagnosticOrderType type : values()) { + if (type.id == id) { + return type; + } + } + throw new IllegalArgumentException("Invalid DiagnosticOrderType ID: " + id); + } + + public static DiagnosticOrderType fromCode(String code) { + for (DiagnosticOrderType type : values()) { + if (type.name().equalsIgnoreCase(code)) { + return type; + } + } + throw new IllegalArgumentException("Invalid DiagnosticOrderType code: " + code); + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java new file mode 100644 index 00000000..a3a5a644 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java @@ -0,0 +1,10 @@ +package com.iemr.flw.masterEnum; + +/** + * Registered diagnostic device integration providers. Add a new constant here when wiring up a + * new DiagnosticProvider implementation (see DiagnosticProviderFactory) - diagnostic.active-provider + * in application properties must be set to one of these names. + */ +public enum DiagnosticProviderCode { + EMRLITE +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java new file mode 100644 index 00000000..4679c400 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticDocument; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticDocumentRepo extends JpaRepository { + + Optional findByDiagnosticOrderIdAndDocumentTypeAndDeletedFalse(Long diagnosticOrderId, String documentType); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java new file mode 100644 index 00000000..5692da36 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -0,0 +1,103 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DiagnosticOrderRepo extends JpaRepository { + + Optional findByExternalOrderId(String externalOrderId); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.benRegID = :benRegID AND o.visitCode = :visitCode " + + "AND o.orderType = :orderType AND o.deleted = false") + Optional findByBenRegIDAndVisitCodeAndOrderType(@Param("benRegID") Long benRegID, + @Param("visitCode") Long visitCode, + @Param("orderType") String orderType); + + Optional findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + Long benRegID, String orderType); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.benRegID = :benRegID AND o.deleted = false ORDER BY o.createdDate DESC") + List findByBenRegID(@Param("benRegID") Long benRegID); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType = 'XRAY_CHEST' " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + + "AND o.deleted = false AND o.testCompletedAt IS NOT NULL " + + "ORDER BY o.lastPolledAt ASC NULLS FIRST") + List findXrayDueForPoll(); + + @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.testCompletedAt IS NOT NULL " + + "ORDER BY o.lastPolledAt ASC NULLS FIRST") + List findTrueNatDueForPoll(); + + @Transactional + @Modifying + @Query("UPDATE DiagnosticOrder o SET o.vanSerialNo = o.id WHERE o.id = :id") + void updateVanSerialNo(@Param("id") Long id); + + // Beneficiary-status-summary queries, filtered the same way as + // FormResponseRepo.findBeneficiaryIdsByFormIdAndStatusFiltered - null-safe optional + // villageId/providerServiceMapId via a correlated BenFlowStatus subquery. + // Each also requires o.id to be the latest (max id) order for its benRegID+orderType, so a + // 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.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.testCompletedAt IS NULL " + + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsAwaitingTestCompletion(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.testCompletedAt IS NOT NULL AND o.status NOT IN ('COMPLETED', 'EXPIRED', 'FAILED') " + + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsAwaitingProviderResult(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.status = 'COMPLETED' " + + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsCompleted(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID 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.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsPollingTimedOut(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID 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 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsFailed(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java new file mode 100644 index 00000000..8f029fd8 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticProviderToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticProviderTokenRepo extends JpaRepository { + + Optional findByProviderCodeAndTokenType(String providerCode, String tokenType); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java new file mode 100644 index 00000000..c8796ff5 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticResult; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticResultRepo extends JpaRepository { + + Optional findByDiagnosticOrderIdAndDeletedFalse(Long diagnosticOrderId); +} diff --git a/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java b/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java new file mode 100644 index 00000000..4899d8c6 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java @@ -0,0 +1,13 @@ +package com.iemr.flw.service; + +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; + +public interface DiagnosticDocumentService { + + void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String externalOrderId, + DiagnosticDocumentAsset asset) throws Exception; + + DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType, Long visitCode) throws Exception; +} diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java new file mode 100644 index 00000000..cb63e0c2 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -0,0 +1,30 @@ +package com.iemr.flw.service; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; +import com.iemr.flw.integration.provider.DiagnosticPollResult; + +import java.util.List; + +public interface DiagnosticOrderService { + + DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception; + + DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult result) throws Exception; + + DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception; + + DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType, Long visitCode) throws Exception; + + DiagnosticOrder markTestCompleted(Long benRegID, String orderType, Long visitCode) throws Exception; + + DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType, Long visitCode); + + DiagnosticOrder getOrder(Long benRegID, String orderType, Long visitCode) throws Exception; + + List getOrdersByBenRegId(Long benRegID) throws Exception; + + DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, Integer providerServiceMapId); +} diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index aef14aaf..4934a3ef 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -518,9 +518,12 @@ private String getMappingsForAddressIDs(List addressLi benDetailsRMNCH_OBJ.setAge_unit(ageUnit); resultMap = new HashMap<>(); - if (benHouseHoldRMNCH_ROBJ != null) + if (benHouseHoldRMNCH_ROBJ != null) { + if (benHouseHoldRMNCH_ROBJ.getAddress() == null + && benAddressOBJ.getPermAddrLine1() != null) + benHouseHoldRMNCH_ROBJ.setAddress(benAddressOBJ.getPermAddrLine1()); resultMap.put("householdDetails", benHouseHoldRMNCH_ROBJ); - else + } else resultMap.put("householdDetails", new HashMap()); if (benBotnBirthRMNCH_ROBJ != null) diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java new file mode 100644 index 00000000..c0c2c919 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -0,0 +1,154 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticDocument; +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; +import com.iemr.flw.repo.iemr.DiagnosticDocumentRepo; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.service.DiagnosticDocumentService; +import com.iemr.flw.utils.CryptoUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +@Service +public class DiagnosticDocumentServiceImpl implements DiagnosticDocumentService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticDocumentServiceImpl.class); + private static final String DEFAULT_CONTENT_TYPE = "application/pdf"; + + @Value("${diagnostic.documents.storage-root}") + private String storageRoot; + + @Autowired + private DiagnosticDocumentRepo diagnosticDocumentRepo; + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private CryptoUtil cryptoUtil; + + @Override + public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String externalOrderId, + DiagnosticDocumentAsset asset) throws Exception { + if (asset == null || asset.getBase64Content() == null) { + logger.warn("Skipping document ingest for benRegID={}, orderType={}: empty asset content", + benRegID, orderType); + return; + } + + byte[] originalBytes = Base64.getDecoder().decode(asset.getBase64Content()); + String sha256Hash = sha256Hex(originalBytes); + + DiagnosticDocumentType documentType = DiagnosticDocumentType.from(orderType, asset.getType()); + Optional existing = diagnosticDocumentRepo + .findByDiagnosticOrderIdAndDocumentTypeAndDeletedFalse(diagnosticOrderId, documentType.name()); + if (existing.isPresent() && sha256Hash.equals(existing.get().getSha256Hash())) { + logger.info("Diagnostic document already stored, skipping duplicate: diagnosticOrderId={}, documentType={}", + diagnosticOrderId, documentType); + return; + } + + long epochTime = Instant.now().toEpochMilli(); + String storedFileName = documentType.name() + ".enc"; + String relativeDir = benRegID + "/" + diagnosticOrderId; + + // Documents must never touch disk unencrypted: encrypt in memory first, write only ciphertext. + String base64Original = Base64.getEncoder().encodeToString(originalBytes); + String encryptedPayload = cryptoUtil.encrypt(base64Original); + + Path dir = Paths.get(storageRoot, relativeDir); + Files.createDirectories(dir); + Path filePath = dir.resolve(storedFileName); + Files.write(filePath, encryptedPayload.getBytes(StandardCharsets.UTF_8)); + + DiagnosticDocument document = existing.orElseGet(DiagnosticDocument::new); + document.setDiagnosticOrderId(diagnosticOrderId); + document.setBenRegID(benRegID); + document.setOrderType(orderType); + document.setAssetType(asset.getType()); + document.setDocumentType(documentType.name()); + document.setEpochTime(epochTime); + document.setStoredFileName(storedFileName); + document.setStoredPath(relativeDir + "/" + storedFileName); + document.setSha256Hash(sha256Hash); + document.setContentType(asset.getContentType() != null ? asset.getContentType() : DEFAULT_CONTENT_TYPE); + document.setOriginalFileName(asset.getFileName()); + document.setCreatedBy("SYSTEM"); + try { + diagnosticDocumentRepo.save(document); + } catch (DataIntegrityViolationException dive) { + logger.warn("Lost document upsert race for diagnosticOrderId={}, documentType={}", diagnosticOrderId, documentType); + return; + } + + logger.info("Diagnostic document ingested: benRegID={}, orderType={}, epochTime={}", + benRegID, orderType, epochTime); + } + + @Override + public DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType, Long visitCode) throws Exception { + String orderType = documentType.impliedOrderType().name(); + DiagnosticOrder order; + if (visitCode != null) { + order = diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType) + .orElseThrow(() -> new Exception("No diagnostic order found for benRegID=" + benRegID + + ", visitCode=" + visitCode + ", orderType=" + orderType)); + } else { + order = diagnosticOrderRepo + .findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, orderType) + .orElseThrow(() -> new Exception( + "No diagnostic order found for benRegID=" + benRegID + ", orderType=" + orderType)); + } + + DiagnosticDocument document = diagnosticDocumentRepo + .findByDiagnosticOrderIdAndDocumentTypeAndDeletedFalse(order.getId(), documentType.name()) + .orElseThrow(() -> new Exception( + "No document found for diagnosticOrderId=" + order.getId() + ", documentType=" + documentType)); + + Path filePath = Paths.get(storageRoot, document.getStoredPath()); + String encryptedPayload = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); + + String base64Original = cryptoUtil.decrypt(encryptedPayload); + if (base64Original == null) { + throw new Exception("Failed to decrypt document for benRegID=" + benRegID + ", documentType=" + documentType); + } + byte[] originalBytes = Base64.getDecoder().decode(base64Original); + + String recomputedHash = sha256Hex(originalBytes); + if (!recomputedHash.equals(document.getSha256Hash())) { + throw new Exception( + "Document integrity check failed for benRegID=" + benRegID + ", documentType=" + documentType); + } + + logger.info("Diagnostic document fetched: benRegID={}, documentType={}, epochTime={}", + benRegID, documentType, document.getEpochTime()); + + return new DiagnosticDocumentContent(originalBytes, document.getContentType()); + } + + private static String sha256Hex(byte[] data) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(String.format(Locale.ROOT, "%02x", b)); + } + return hex.toString(); + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java new file mode 100644 index 00000000..fbc68348 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -0,0 +1,308 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.domain.iemr.DiagnosticResult; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.integration.provider.DiagnosticProvider; +import com.iemr.flw.integration.provider.DiagnosticProviderFactory; +import com.iemr.flw.integration.provider.DiagnosticPushResult; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.repo.iemr.DiagnosticResultRepo; +import com.iemr.flw.service.DiagnosticDocumentService; +import com.iemr.flw.service.DiagnosticOrderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; + +@Service +public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticOrderServiceImpl.class); + + @Value("${diagnostic.active-provider}") + private String activeProvider; + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private DiagnosticResultRepo diagnosticResultRepo; + + @Autowired + private DiagnosticProviderFactory providerFactory; + + @Autowired + private DiagnosticDocumentService diagnosticDocumentService; + + @Override + public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { + Long benRegID = request.getBenRegID(); + Long visitCode = request.getVisitCode(); + DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); + String orderEvent = request.getOrderEvent(); + String patientFirstName = request.getPatient().getFirstName(); + String patientLastName = request.getPatient().getLastName(); + String patientDateOfBirth = request.getPatient().getDateOfBirth(); + String patientSex = request.getPatient().getSex(); + + String providerCode = activeProvider; + String externalOrderId = String.format("%d-%d-%s", benRegID, visitCode, orderType.name()); + + Optional existing = + diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType.name()); + + if (existing.isPresent() && !DiagnosticOrderStatus.FAILED.name().equals(existing.get().getStatus())) { + return existing.get(); + } + + DiagnosticOrder order = existing.orElseGet(DiagnosticOrder::new); + order.setOrderEvent(orderEvent); + order.setBenRegID(benRegID); + order.setVisitCode(visitCode); + order.setProviderServiceName(providerCode); + 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); + order.setPatientSex(patientSex); + try { + order = diagnosticOrderRepo.save(order); + } catch (DataIntegrityViolationException dive) { + Optional winner = diagnosticOrderRepo + .findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType.name()); + if (winner.isPresent()) { + logger.warn("Lost create race for benRegID={}, visitCode={}, orderType={} — returning existing order id={}", + benRegID, visitCode, orderType, winner.get().getId()); + return winner.get(); + } + throw dive; + } + + try { + DiagnosticProvider provider = providerFactory.getProvider(providerCode); + DiagnosticPushResult pushResult = provider.pushOrder(order); + order.setPushResponseJson(pushResult.getRawResponseJson()); + if (pushResult.isSuccess()) { + order.setProviderOrderId(pushResult.getProviderOrderId()); + } else { + order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setErrorMessage(pushResult.getErrorMessage()); + } + } catch (Exception e) { + logger.error("Failed to push order to provider, orderId={}: {}", order.getId(), e.getMessage()); + order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setErrorMessage(e.getMessage()); + } + order = diagnosticOrderRepo.save(order); + + return order; + } + + @Override + public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { + Optional existingResult = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()); + DiagnosticResult result = existingResult.orElseGet(DiagnosticResult::new); + result.setDiagnosticOrderId(order.getId()); + result.setBenRegID(order.getBenRegID()); + result.setProviderStatus(pollResult.getStatus().name()); + result.setResultSummary(pollResult.getResultSummary()); + result.setRawResponseJson(pollResult.getRawResponseJson()); + result.setTbPresence(pollResult.getTbPresence()); + result.setTbConfidence(pollResult.getTbConfidence()); + result.setDrugResistancePresence(pollResult.getDrugResistancePresence()); + result.setCreatedBy("SYSTEM"); + try { + diagnosticResultRepo.save(result); + } catch (DataIntegrityViolationException dive) { + logger.warn("Lost result upsert race for diagnosticOrderId={}", order.getId()); + result = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()).orElse(result); + } + + if (pollResult.getAssets() != null) { + for (DiagnosticDocumentAsset asset : pollResult.getAssets()) { + try { + diagnosticDocumentService.ingestAsset(order.getId(), order.getBenRegID(), order.getOrderType(), + order.getExternalOrderId(), asset); + } catch (Exception e) { + logger.error("Failed to ingest document asset for orderId={}, assetType={}, fileName={}: {}", + order.getId(), asset.getType(), asset.getFileName(), e.getMessage()); + } + } + } + + order.setStatus(pollResult.getStatus().name()); + order.setErrorMessage(pollResult.getErrorMessage()); + if (pollResult.getProviderOrderId() != null) { + order.setProviderOrderId(pollResult.getProviderOrderId()); + } + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + diagnosticOrderRepo.save(order); + + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setExternalOrderId(order.getExternalOrderId()); + dto.setOrderType(order.getOrderType()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + dto.setProviderStatus(result.getProviderStatus()); + dto.setResultSummary(result.getResultSummary()); + dto.setTbPresence(result.getTbPresence()); + dto.setTbConfidence(result.getTbConfidence()); + dto.setDrugResistancePresence(result.getDrugResistancePresence()); + return dto; + } + + @Override + public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { + DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); + DiagnosticPollResult result = provider.pollResult(order, false); + if (DiagnosticOrderStatus.COMPLETED.equals(result.getStatus())) { + result = provider.pollResult(order, true); + } + return result; + } + + private DiagnosticOrder findLatestOrder(Long benRegID, String orderType) throws Exception { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + return diagnosticOrderRepo + .findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, type.name()) + .orElseThrow(() -> new Exception( + "DiagnosticOrder not found for benRegID=" + benRegID + ", orderType=" + orderType)); + } + + // Resolves a specific order by visitCode when given (retest disambiguation), otherwise + // falls back to "latest" - matching the pre-multi-order default callers already rely on. + private DiagnosticOrder resolveOrder(Long benRegID, String orderType, Long visitCode) throws Exception { + if (visitCode == null) { + return findLatestOrder(benRegID, orderType); + } + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + return diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, type.name()) + .orElseThrow(() -> new Exception("DiagnosticOrder not found for benRegID=" + benRegID + + ", visitCode=" + visitCode + ", orderType=" + orderType)); + } + + @Override + public DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType, Long visitCode) throws Exception { + DiagnosticOrder order = resolveOrder(benRegID, orderType, visitCode); + DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); + DiagnosticPollResult pollResult = provider.pollResult(order, true); + return processResult(order, pollResult); + } + + @Override + public DiagnosticOrder markTestCompleted(Long benRegID, String orderType, Long visitCode) throws Exception { + DiagnosticOrder order = resolveOrder(benRegID, orderType, visitCode); + String status = order.getStatus(); + + if (order.getTestCompletedAt() != null) { + Optional existingResult = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()); + boolean isTerminal = DiagnosticOrderStatus.COMPLETED.name().equals(status) + || DiagnosticOrderStatus.FAILED.name().equals(status) + || DiagnosticOrderStatus.EXPIRED.name().equals(status); + if (existingResult.isPresent() && isTerminal) { + // Re-test: the order already completed once and has a result on file, and the + // physical test was performed again — reopen it so the scheduler picks it back up + // and polls for the new result instead of leaving the stale one in place forever. + // Reset to PENDING (not IN_PROGRESS) so it mirrors a freshly-pushed order's + // starting state rather than looking like it's already mid-poll. + order.setTestCompletedAt(new Timestamp(System.currentTimeMillis())); + order.setStatus(DiagnosticOrderStatus.PENDING.name()); + order.setErrorMessage(null); + DiagnosticOrder savedOrder = diagnosticOrderRepo.save(order); + + // The stale result's own providerStatus also needs resetting - otherwise it keeps + // reporting the previous test's COMPLETED/FAILED status while the order itself + // shows PENDING, which is an inconsistent picture until the next poll overwrites it. + DiagnosticResult result = existingResult.get(); + result.setProviderStatus(DiagnosticOrderStatus.PENDING.name()); + diagnosticResultRepo.save(result); + + return savedOrder; + } + return order; // idempotent — already flagged, no result yet, don't reset the poll clock + } + + if (DiagnosticOrderStatus.COMPLETED.name().equals(status) + || DiagnosticOrderStatus.FAILED.name().equals(status) + || DiagnosticOrderStatus.CANCELLED.name().equals(status)) { + throw new IllegalStateException("Cannot mark test completed for order in terminal status " + status); + } + order.setTestCompletedAt(new Timestamp(System.currentTimeMillis())); + order.setStatus(DiagnosticOrderStatus.IN_PROGRESS.name()); + return diagnosticOrderRepo.save(order); + } + + @Override + public DiagnosticOrder getOrder(Long benRegID, String orderType, Long visitCode) throws Exception { + return resolveOrder(benRegID, orderType, visitCode); + } + + @Override + public List getOrdersByBenRegId(Long benRegID) throws Exception { + return diagnosticOrderRepo.findByBenRegID(benRegID); + } + + @Override + public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType, Long visitCode) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setOrderType(orderType); + + Optional orderOpt = visitCode != null + ? diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType) + : diagnosticOrderRepo.findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, orderType); + if (orderOpt.isEmpty()) { + dto.setStatus("NOT_FOUND"); + return dto; + } + + DiagnosticOrder order = orderOpt.get(); + dto.setExternalOrderId(order.getExternalOrderId()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + + diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()).ifPresent(result -> { + dto.setProviderStatus(result.getProviderStatus()); + dto.setResultSummary(result.getResultSummary()); + dto.setTbPresence(result.getTbPresence()); + dto.setTbConfidence(result.getTbConfidence()); + dto.setDrugResistancePresence(result.getDrugResistancePresence()); + }); + return dto; + } + + @Override + public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, + Integer providerServiceMapId) { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + List awaitingTestCompletion = diagnosticOrderRepo + .findBenRegIDsAwaitingTestCompletion(type.name(), villageId, providerServiceMapId); + List awaitingProviderResult = diagnosticOrderRepo + .findBenRegIDsAwaitingProviderResult(type.name(), villageId, providerServiceMapId); + List completed = diagnosticOrderRepo + .findBenRegIDsCompleted(type.name(), villageId, providerServiceMapId); + List pollingTimedOut = diagnosticOrderRepo + .findBenRegIDsPollingTimedOut(type.name(), villageId, providerServiceMapId); + List failed = diagnosticOrderRepo + .findBenRegIDsFailed(type.name(), villageId, providerServiceMapId); + return new DiagnosticOrderStatusSummaryDto(awaitingTestCompletion, awaitingProviderResult, completed, pollingTimedOut, failed); + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java new file mode 100644 index 00000000..360c37f3 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -0,0 +1,115 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.service.DiagnosticOrderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +// Two independent schedulers, one per order family: XRAY_CHEST polls immediately on its own +// cadence, while TrueNat waits truenatInitialDelayMinutes after test completion before polling +// on its own (slower) cadence. +@Service +public class DiagnosticPollSchedulerService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticPollSchedulerService.class); + + @Value("${diagnostic.poll.give-up-minutes}") + private int giveUpMinutes; + + @Value("${diagnostic.poll.truenat.initial-delay-minutes}") + private int truenatInitialDelayMinutes; + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private DiagnosticOrderService diagnosticOrderService; + + @Scheduled(fixedDelayString = "${diagnostic.poll.xray.tick-ms:15000}") + public void pollXrayOrders() { + List candidates = diagnosticOrderRepo.findXrayDueForPoll(); + logger.info("XRAY poll tick: {} order(s) in due-for-poll queue", candidates.size()); + if (candidates.isEmpty()) return; + + Instant now = Instant.now(); + int polled = 0; + for (DiagnosticOrder order : candidates) { + Instant testCompletedAt = order.getTestCompletedAt().toInstant(); + if (isExpired(testCompletedAt, now)) { + giveUp(order); + } else { + pollSingle(order); + polled++; + } + } + if (polled > 0) { + logger.info("Polled {} pending XRAY diagnostic orders", polled); + } + } + + @Scheduled(fixedDelayString = "${diagnostic.poll.truenat.tick-ms:60000}") + public void pollTrueNatOrders() { + List candidates = diagnosticOrderRepo.findTrueNatDueForPoll(); + logger.info("TrueNat poll tick: {} order(s) in due-for-poll queue", candidates.size()); + if (candidates.isEmpty()) return; + + Instant now = Instant.now(); + int polled = 0; + for (DiagnosticOrder order : candidates) { + Instant pollingStartedAt = order.getTestCompletedAt().toInstant() + .plus(truenatInitialDelayMinutes, ChronoUnit.MINUTES); + if (pollingStartedAt.isAfter(now)) { + continue; + } + if (isExpired(pollingStartedAt, now)) { + giveUp(order); + } else { + pollSingle(order); + polled++; + } + } + if (polled > 0) { + logger.info("Polled {} pending TrueNat diagnostic orders", polled); + } + } + + // Measured from when polling actually starts for this order (immediately for X-ray, after the + // initial delay for TrueNat), so one give-up-minutes value works for both families. + private boolean isExpired(Instant pollingStartedAt, Instant now) { + Instant deadline = pollingStartedAt.plus(giveUpMinutes, ChronoUnit.MINUTES); + return !deadline.isAfter(now); + } + + private void giveUp(DiagnosticOrder order) { + order.setStatus(DiagnosticOrderStatus.EXPIRED.name()); + order.setErrorMessage("Polling window exceeded (" + giveUpMinutes + " min) without a result"); + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + diagnosticOrderRepo.save(order); + logger.warn("Order {} exceeded give-up window of {} min, marked EXPIRED", order.getId(), giveUpMinutes); + } + + private void pollSingle(DiagnosticOrder order) { + try { + DiagnosticPollResult result = diagnosticOrderService.pollOnce(order); + diagnosticOrderService.processResult(order, result); + } catch (Exception e) { + logger.error("Poll failed for orderId={}: {}", order.getId(), e.getMessage()); + order.setRetryCount(order.getRetryCount() + 1); + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setErrorMessage(e.getMessage()); + diagnosticOrderRepo.save(order); + } + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index f73eac4e..8fe1e273 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -380,6 +380,7 @@ public List> saveGeneralExamination(List Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", visit.getVisitCode()); result.put("referralToHWCNeeded", exam.getReferralToHWCNeeded()); results.add(result); } @@ -418,6 +419,7 @@ private Map examToMap(StopTBGeneralExamination e) { Map m = new LinkedHashMap<>(); m.put("id", e.getId()); m.put("beneficiaryRegID", e.getBeneficiaryRegID()); + m.put("visitCode", e.getVisitCode()); m.put("providerServiceMapID", e.getProviderServiceMapID()); m.put("pulseRate", e.getPulseRate()); m.put("systolicBP", e.getSystolicBP()); @@ -518,6 +520,7 @@ public List> saveNurseTBScreening(List> Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", visit.getVisitCode()); results.add(result); } return results; @@ -547,6 +550,7 @@ private Map screeningToMap(TBScreening s) { Map m = new LinkedHashMap<>(); m.put("id", s.getId()); m.put("beneficiaryRegID", s.getBenRegID()); + m.put("visitCode", s.getVisitCode()); m.put("providerServiceMapID", s.getProviderServiceMapID()); m.put("coughMoreThan2Weeks", s.getCoughMoreThan2Weeks()); m.put("bloodInSputum", s.getBloodInSputum()); @@ -628,6 +632,7 @@ public List> saveGeneralOpd(List> dataLi Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", visit.getVisitCode()); results.add(result); } return results; @@ -657,6 +662,7 @@ private Map opdToMap(StopTBGeneralOpd o) { Map m = new LinkedHashMap<>(); m.put("id", o.getId()); m.put("beneficiaryRegID", o.getBenRegID()); + m.put("visitCode", o.getVisitCode()); m.put("providerServiceMapID", o.getProviderServiceMapID()); m.put("chiefComplaint", o.getChiefComplaint()); m.put("medication", o.getMedication());