diff --git a/pom.xml b/pom.xml index 36f151d1f..63e8f2ff9 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.11.0 + 3.12.0 war FLW-API diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 862e4c314..b778ecfff 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -64,6 +64,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=@env.DIAGNOSTIC_EMRLITE_XRAY_USERNAME@ diagnostic.emrlite.xray.password=@env.DIAGNOSTIC_EMRLITE_XRAY_PASSWORD@ @@ -74,6 +75,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=@env.DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME@ diagnostic.emrlite.truenat.password=@env.DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 6c1f908c6..e38175a51 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -70,6 +70,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} @@ -80,6 +81,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index d85fa61a5..a1d4ebe55 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,11 +23,17 @@ springdoc.swagger-ui.enabled=true logging.path=logs/ logging.file.name=logs/flw-api.log -jwt.secret=my-32-character-ultra-secure-and-ultra-long-secret +jwt.secret=eefa40497bb9b2a8491b85069aff62e7596521c6816e471c464181b52eac7670ff91394a # Redis Config spring.redis.host=localhost +# Stop TB: when true, saves fail with an error if camp (vanID) is not configured +# instead of silently storing vanID=NULL (which then never gets synced to central) +stoptb.enforce.vanid=false +# Stop TB: this deployment's van/camp ID, replacing the old Redis camp:vanID lookup +stoptb.van.id=0 + cors.allowed-origins=http://localhost:* source-address= @@ -44,56 +49,46 @@ abha.client.secret= abha.token.url= abha.xcmid= - - - - -# Diagnostic providers - -# Provider names: use the values supported by your application -diagnostic.provider.xray= -diagnostic.provider.truenat= - -# EMR Lite - Xray (base URL without trailing slash) -diagnostic.emrlite.xray.base-url=https:// +# GovThealth API Config +govthealth.user.details.url=${GOVTHEALTH_USER_DETAILS_URL} +govthealth.user.id=${GOVTHEALTH_USER_ID} +govthealth.password=${GOVTHEALTH_PASSWORD} + +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.provider.xray / diagnostic.provider.truenat select the vendor per orderType family +# (XRAY_CHEST vs MTB/MTB_PLUS/MDR_RIF), each must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode +diagnostic.provider.xray=${DIAGNOSTIC_PROVIDER_XRAY} +diagnostic.provider.truenat=${DIAGNOSTIC_PROVIDER_TRUENAT} + +# EMR Lite - xray group (same base-url as truenat for now; split so a distinct vendor/tenant can be +# swapped in for either group later without a code change) +diagnostic.emrlite.xray.base-url=${DIAGNOSTIC_EMRLITE_XRAY_BASE_URL} diagnostic.emrlite.xray.login-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/login/ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ -diagnostic.emrlite.xray.username= -diagnostic.emrlite.xray.password= +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ +diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} +diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} -# EMR Lite - Truenat -diagnostic.emrlite.truenat.base-url=https:// +# EMR Lite - truenat group (MTB/MTB_PLUS/MDR_RIF); same value as xray for now +diagnostic.emrlite.truenat.base-url=${DIAGNOSTIC_EMRLITE_TRUENAT_BASE_URL} diagnostic.emrlite.truenat.login-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/login/ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/getAccessToken/ diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ -diagnostic.emrlite.truenat.username= -diagnostic.emrlite.truenat.password= - -# Token TTL: replace with vendor-configured durations in seconds -diagnostic.emrlite.refresh-ttl-seconds=86400 +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ +diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} +diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} -# Example: polling every 60 seconds -diagnostic.poll.xray.tick-ms=60000 -diagnostic.poll.truenat.tick-ms=60000 +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} -# Example: daily expiry at 11:59 PM in scheduler timezone -diagnostic.poll.expiry-cron=0 59 23 * * * +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} +# Daily cutoff (cron expression) after which all outstanding PENDING/IN_PROGRESS orders are marked EXPIRED +diagnostic.poll.expiry-cron=${DIAGNOSTIC_POLL_EXPIRY_CRON} -# Local folder (Windows: use forward slashes) -diagnostic.documents.storage-root=C:/amrit/diagnostic-documents -stoptb.enforce.vanid=false -# Stop TB: this deployment's van/camp ID, replacing the old Redis camp:vanID lookup -stoptb.van.id=0 - - - -# GovThealth API Config -govthealth.user.details.url=@env.GOVTHEALTH_USER_DETAILS_URL@ -govthealth.user.id=@env.GOVTHEALTH_USER_ID@ -govthealth.password=@env.GOVTHEALTH_PASSWORD@ -common-api-base-url= +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java index 8eb9476b7..1725b5afc 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -37,10 +37,11 @@ public DiagnosticOrderController(DiagnosticOrderService diagnosticOrderService) @PostMapping("/order/push") @Operation(summary = "Push a diagnostic order to the configured provider") - public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { + public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrder order = diagnosticOrderService.createAndPushOrder(request); + DiagnosticOrder order = diagnosticOrderService.createAndPushOrderByUser(request, jwtToken); response.setResponse(new Gson().toJson(order)); } catch (Exception e) { logger.error("Error in pushOrder: {}", e.getMessage()); @@ -128,14 +129,17 @@ public String pollOrder(@RequestParam Long beneficiaryId, @RequestParam String o } @PostMapping("/order/manualResult") - @Operation(summary = "Manually submit a diagnostic result for a beneficiary's latest order of the given " - + "type, for use when no vendor device is integrated. Stored as-is; tbPresence, tbConfidence, and " - + "drugResistancePresence are left null since nothing derives them. Rejected if that order is " - + "already COMPLETED.") - public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request) { + @Operation(summary = "Exactly one of resultSummary/reasonToClose is required. resultSummary: manually submit " + + "a diagnostic result for a beneficiary's latest order of the given type, for use when no vendor " + + "device is integrated. Stored as-is; tbPresence, tbConfidence, and drugResistancePresence are left " + + "null since nothing derives them. Rejected if that order is already COMPLETED. reasonToClose: close " + + "the order instead (same behavior as reasonToClose on /order/push), sourcing patient/orderEvent " + + "from the beneficiary's existing order for this type.") + public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request); + DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request, jwtToken); response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in submitManualResult: {}", e.getMessage()); diff --git a/src/main/java/com/iemr/flw/controller/DynamicFormController.java b/src/main/java/com/iemr/flw/controller/DynamicFormController.java index 59295cbf7..254313e93 100644 --- a/src/main/java/com/iemr/flw/controller/DynamicFormController.java +++ b/src/main/java/com/iemr/flw/controller/DynamicFormController.java @@ -22,6 +22,7 @@ package com.iemr.flw.controller; import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.LatestFormVersionDTO; import com.iemr.flw.service.DynamicFormDefinitionService; import com.iemr.flw.utils.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -35,6 +36,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * REST controller for dynamic form definition management. * All write operations use POST; all read operations use GET. @@ -97,4 +100,10 @@ public ResponseEntity deactivateForm( return ResponseEntity.ok(new ApiResponse(true, "Form deactivated", null)); } + @Operation(summary = "Get the latest version of each active form (formId, formUuid, formName, currentVersionNumber)") + @RequestMapping(value = "/getLatestFormVersions", method = RequestMethod.GET) + public ResponseEntity getLatestFormVersions() { + List result = formService.getLatestFormVersions(); + return ResponseEntity.ok(new ApiResponse(true, "Latest form versions fetched successfully", result)); + } } diff --git a/src/main/java/com/iemr/flw/controller/StopTBController.java b/src/main/java/com/iemr/flw/controller/StopTBController.java index 7f73d29e2..e9a38513c 100644 --- a/src/main/java/com/iemr/flw/controller/StopTBController.java +++ b/src/main/java/com/iemr/flw/controller/StopTBController.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.iemr.flw.service.StopTBPrescriptionService; import com.iemr.flw.service.StopTBService; import com.iemr.flw.utils.response.OutputResponse; import io.swagger.v3.oas.annotations.Operation; @@ -51,7 +52,7 @@ public String getAllGeneralExaminations(@RequestBody Map body) { if (raw == null) throw new Exception("providerServiceMapID is required"); Integer villageID = body.get("villageID") != null ? Integer.parseInt(body.get("villageID").toString()) : null; Map data = stopTBService.getAllGeneralExaminations(Integer.parseInt(raw.toString()), villageID); - response.setResponse(gson.toJson(data)); + response.setResponse(gson.toJsonTree(data)); } catch (Exception e) { logger.error("Error in getAllGeneralExaminations: " + e); response.setError(5000, "Error fetching general examinations: " + e.getMessage()); @@ -84,7 +85,7 @@ public String getAllNurseTBScreenings(@RequestBody Map body) { if (raw == null) throw new Exception("providerServiceMapID is required"); Integer villageID = body.get("villageID") != null ? Integer.parseInt(body.get("villageID").toString()) : null; Map data = stopTBService.getAllNurseTBScreenings(Integer.parseInt(raw.toString()), villageID); - response.setResponse(gson.toJson(data)); + response.setResponse(gson.toJsonTree(data)); } catch (Exception e) { logger.error("Error in getAllNurseTBScreenings: " + e); response.setError(5000, "Error fetching TB screenings: " + e.getMessage()); @@ -101,6 +102,10 @@ public String saveGeneralOpd(@RequestBody List> dataList) { try { List> result = stopTBService.saveGeneralOpd(dataList); response.setResponse(gson.toJson(result)); + } catch (StopTBPrescriptionService.InsufficientStockException e) { + // Nothing was saved; the app shows this and lets the nurse lower the quantity. + logger.warn("saveGeneralOpd rejected: " + e.getMessage()); + response.setError(5000, e.getMessage()); } catch (Exception e) { logger.error("Error in saveGeneralOpd: " + e); response.setError(5000, "Error saving general OPD: " + e.getMessage()); @@ -117,7 +122,7 @@ public String getAllGeneralOpd(@RequestBody Map body) { if (raw == null) throw new Exception("providerServiceMapID is required"); Integer villageID = body.get("villageID") != null ? Integer.parseInt(body.get("villageID").toString()) : null; Map data = stopTBService.getAllGeneralOpd(Integer.parseInt(raw.toString()), villageID); - response.setResponse(gson.toJson(data)); + response.setResponse(gson.toJsonTree(data)); } catch (Exception e) { logger.error("Error in getAllGeneralOpd: " + e); response.setError(5000, "Error fetching general OPD records: " + e.getMessage()); @@ -150,7 +155,7 @@ public String getAllDiagnostics(@RequestBody Map body) { if (raw == null) throw new Exception("providerServiceMapID is required"); Integer villageID = body.get("villageID") != null ? Integer.parseInt(body.get("villageID").toString()) : null; Map data = stopTBService.getAllDiagnostics(Integer.parseInt(raw.toString()), villageID); - response.setResponse(gson.toJson(data)); + response.setResponse(gson.toJsonTree(data)); } catch (Exception e) { logger.error("Error in getAllDiagnostics: " + e); response.setError(5000, "Error fetching diagnostics records: " + e.getMessage()); diff --git a/src/main/java/com/iemr/flw/controller/StopTBSyncController.java b/src/main/java/com/iemr/flw/controller/StopTBSyncController.java new file mode 100644 index 000000000..23201436f --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/StopTBSyncController.java @@ -0,0 +1,77 @@ +package com.iemr.flw.controller; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.iemr.flw.service.StopTBSyncService; +import com.iemr.flw.utils.JwtUtil; +import com.iemr.flw.utils.response.OutputResponse; +import io.swagger.v3.oas.annotations.Operation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/stoptb/sync") +public class StopTBSyncController { + + private final Logger logger = LoggerFactory.getLogger(StopTBSyncController.class); + + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @Autowired + private StopTBSyncService stopTBSyncService; + + @Autowired + private JwtUtil jwtUtil; + + /** + * Headers: JwtToken (required; the user's role mappings decide the allowed service and villages). + * Body: { token: string|null, providerServiceMapID: int, villageIds: [int] (empty = all of the user's + * villages), types: [string], limit: int }. + * types: the record types the app applies (all when omitted); changing it requires a new snapshot. + * Response: { supported, serverId, snapshot, items: [{type, op, key, version, data}], nextToken, hasMore }. + * Call again with nextToken while hasMore is true. On a server without the change log (AMRIT-DB before V132) the + * response is { supported: false } and the app keeps using the getAll endpoints. + */ + @PostMapping("/changes") + @Operation(summary = "STOP-TB records changed since the token (first call: everything in the villages)") + public String getChanges(@RequestBody Map body, + @RequestHeader(value = "Authorization", required = false) String authorization, + @RequestHeader(value = "JwtToken", required = false) String jwtToken) { + OutputResponse response = new OutputResponse(); + try { + // The user decides which villages may be synced, so the JWT is required here even + // where the request filter would let a mobile request through without one. + if (jwtToken == null || jwtToken.isBlank()) throw new IllegalArgumentException("JwtToken header is required"); + Integer userId = jwtUtil.extractUserId(jwtToken); + Object token = body.get("token"); + Integer psm = body.get("providerServiceMapID") != null + ? Integer.parseInt(body.get("providerServiceMapID").toString()) : null; + List villageIds = new ArrayList<>(); + if (body.get("villageIds") instanceof List) { + for (Object v : (List) body.get("villageIds")) { + if (v != null) villageIds.add((int) Double.parseDouble(v.toString())); + } + } + List types = new ArrayList<>(); + if (body.get("types") instanceof List) { + for (Object t : (List) body.get("types")) { + if (t != null) types.add(t.toString()); + } + } + Integer limit = body.get("limit") != null ? (int) Double.parseDouble(body.get("limit").toString()) : null; + Map data = stopTBSyncService.getChanges( + userId, token != null ? token.toString() : null, psm, villageIds, types, limit, authorization); + response.setResponse(gson.toJson(data)); + } catch (Exception e) { + logger.error("Error in sync/changes: " + e); + response.setError(5000, "Error fetching changes: " + e.getMessage()); + } + return response.toString(); + } +} diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java index d96ee5abc..8354670ee 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java @@ -102,11 +102,13 @@ public class RMNCHBeneficiaryDetailsRmnch { @Column(name = "longitude") private BigDecimal longitude; - @Expose @Column(name = "digipin") private String digipin; + @Expose + @Column(name = "gpsTimestamp") + private Timestamp gpsTimestamp; @Expose @Column(name = "isGpsUnavailable", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java index 508617747..8d8ecff25 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java @@ -84,7 +84,7 @@ public class DiagnosticDocument { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; @Column(name = "synced_by") diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java index ae7dc52db..5ea9a4589 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -8,8 +8,7 @@ import java.sql.Timestamp; @Entity -@Table(name = "tb_diagnostic_order", schema = "db_iemr", - uniqueConstraints = @UniqueConstraint(columnNames = {"beneficiary_id", "visitCode", "order_type"})) +@Table(name = "tb_diagnostic_order", schema = "db_iemr") @Data public class DiagnosticOrder { @@ -59,6 +58,9 @@ public class DiagnosticOrder { @Column(name = "push_response_json", columnDefinition = "LONGTEXT") private String pushResponseJson; + @Column(name = "cancel_response_json", columnDefinition = "LONGTEXT") + private String cancelResponseJson; + @Column(name = "patient_first_name", length = 100) private String patientFirstName; @@ -82,6 +84,9 @@ public class DiagnosticOrder { @Column(name = "modified_by") private String modifiedBy; + @Column(name = "manually_entered_by") + private String manuallyEnteredBy; + @UpdateTimestamp @Column(name = "last_mod_date") private Timestamp lastModDate; @@ -101,7 +106,7 @@ public class DiagnosticOrder { @Column(name = "processed") private String processed = "N"; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; @Column(name = "synced_by") diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java index 6ee5ea126..198667f38 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java @@ -46,7 +46,7 @@ public class DiagnosticProviderToken { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; @Column(name = "synced_by") diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java index 734e98e98..55e5a0ca0 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java @@ -70,7 +70,7 @@ public class DiagnosticResult { @Column(name = "parkingPlaceID") private Integer parkingPlaceID; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; @Column(name = "synced_by") diff --git a/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java new file mode 100644 index 000000000..1ff0158d7 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java @@ -0,0 +1,53 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.sql.Timestamp; + +/** + * One row per applied {@code com.iemr.flw.seeder.migration.FormStructureMigration}, keyed by its + * stable migrationId — lets {@code DynamicFormMigrationRunner} skip a migration it has already run. + */ +@Entity +@Table(name = "t_dynamic_form_migration_log", schema = "db_iemr") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DynamicFormMigrationLog { + + @Id + @Column(name = "migrationId", length = 50) + private String migrationId; + + @CreationTimestamp + @Column(name = "appliedAt", nullable = false, updatable = false) + private Timestamp appliedAt; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java index b06ce48e7..0f5e687ee 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java @@ -90,6 +90,11 @@ public class FormSection { @Column(name = "isEditable", nullable = false) private Boolean isEditable = false; + /** Soft-delete flag: false means removed from its version. Never hard-deleted — historical + * section responses reference it. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java index 397fc22f6..1f26da77d 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java @@ -71,6 +71,11 @@ public class FormVersion { @Column(name = "isLatest", nullable = false) private Boolean isLatest = true; + /** Soft-delete flag: false means the version is retired. Never hard-deleted — historical + * form responses reference their version. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "createdAt", nullable = false, updatable = false) private Timestamp createdAt; diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index 975a23ad2..3a8908466 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -74,6 +74,10 @@ public class OptionCondition { @ToString.Exclude private FormSection targetSection; + /** Soft-delete flag: false means removed from its option. Never hard-deleted. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index c732045a0..cbbbdc83d 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -21,6 +21,7 @@ */ package com.iemr.flw.domain.iemr; +import com.iemr.flw.masterEnum.FormType; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -70,12 +71,21 @@ public class QuestionOption { @Column(name = "optionValue", nullable = false, length = 100) private String optionValue; + /** Stable across every version of a form (formType + optionValue), unlike optionId — see {@link #buildOptionUuid}. */ + @Column(name = "optionUuid", length = 255) + private String optionUuid; + @Column(name = "optionValue_hindi", length = 100) private String optionValueHindi; @Column(name = "displayOrder", nullable = false) private Integer displayOrder; + /** Soft-delete flag: false means removed from its question. Never hard-deleted — historical + * QuestionResponse rows reference optionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; @@ -107,4 +117,12 @@ public class QuestionOption { @Column(name = "SyncFailureReason") private String syncFailureReason; + + /** e.g. (OCCUPATION_CONTACT_TRACING, "Yes") → "OCCUPATION_CONTACT_TRACING_YES". */ + public static String buildOptionUuid(FormType formType, String optionValue) { + String normalized = optionValue.toUpperCase() + .replaceAll("[^A-Z0-9]+", "_") + .replaceAll("^_+|_+$", ""); + return formType.name() + "_" + normalized; + } } diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java index 300853c53..ee2c3a34d 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java @@ -72,6 +72,10 @@ public class QuestionValidation { @Column(name = "errorMessage", nullable = false, length = 500) private String errorMessage; + /** Soft-delete flag: false means removed from its question. Never hard-deleted. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 67fc109f6..5660405ce 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -98,6 +98,11 @@ public class SectionQuestion { @Column(name = "containsPii", nullable = false) private Boolean containsPii = false; + /** Soft-delete flag: false means unlinked from its section. Never hard-deleted — historical + * QuestionResponse rows reference questionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java b/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java index 400074a06..14e4aba6a 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java +++ b/src/main/java/com/iemr/flw/domain/iemr/StopTBDiagnostics.java @@ -135,7 +135,7 @@ public class StopTBDiagnostics { @Column(name = "processed") private String processed = "N"; - @Column(name = "vanSerialNo") + @Column(name = "vanSerialNo", updatable = false) private Long vanSerialNo; @Column(name = "synced_by") diff --git a/src/main/java/com/iemr/flw/domain/iemr/TBScreening.java b/src/main/java/com/iemr/flw/domain/iemr/TBScreening.java index 01170cf91..4af3c078b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/TBScreening.java +++ b/src/main/java/com/iemr/flw/domain/iemr/TBScreening.java @@ -195,28 +195,21 @@ public class TBScreening { @Column(name = "createdDate") private java.sql.Timestamp createdDate; - @Column(name = "risk_factor", columnDefinition = "TEXT") - private String riskFactor; - - @Column(name = "fatigue") - private Boolean fatigue; - @Column(name = "chest_pain") private Boolean chestPain; - @Column(name = "short_breath") - private Boolean shortBreath; + @Column(name = "shortness_of_breath") + private Boolean shortnessOfBreath; - @Column(name = "synced_by") - private String syncedBy; - - @org.hibernate.annotations.CreationTimestamp - @Column(name = "synced_date") - private Timestamp syncedDate; + @Column(name = "fatigue") + private Boolean fatigue; - @Column(name = "failure_to_gainWeight") + @Column(name = "failure_to_gain_weight") private Boolean failureToGainWeight; - @Column(name = "decreased_activity_playfulness ") - private Boolean decreasedActivityOrPlayfulness ; + @Column(name = "decreased_activity_or_playfulness") + private Boolean decreasedActivityOrPlayfulness; + + @Column(name = "others") + private Boolean others; } diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java index 6ed0e03fe..abf8475ea 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -14,15 +14,12 @@ public class DiagnosticOrderRequestDto { @NotNull private Long beneficiaryId; - @NotNull - private Long visitCode; - @NotNull private String orderType; private String orderEvent; - private String reasonForRefusal; + private String reasonToClose; @NotNull @Valid diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java index 92eaa03d5..531800f7d 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -13,7 +13,7 @@ public class DiagnosticOrderResultDto { private String providerStatus; private String resultSummary; private String errorMessage; - private String reasonForRefusal; + private String reasonToClose; private Boolean tbPresence; private Double tbConfidence; private Boolean drugResistancePresence; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java index 4e18e0bb1..88eafcab7 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -12,8 +12,7 @@ public class DiagnosticOrderStatusSummaryDto { private List awaitingProviderResult; private List completed; - private List pollingTimedOut; private List failed; - private List refused; + private List closed; private List awaitingManualEntry; } diff --git a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java index 12bd70cdb..cd6a10a83 100644 --- a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java @@ -1,6 +1,5 @@ package com.iemr.flw.dto; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.NoArgsConstructor; @@ -15,6 +14,7 @@ public class ManualDiagnosticResultRequestDto { @NotNull private String orderType; - @NotBlank private String resultSummary; + + private String reasonToClose; } diff --git a/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java b/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java index 9d755546a..38fe27058 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java @@ -21,6 +21,7 @@ */ package com.iemr.flw.dto.iemr; +import com.fasterxml.jackson.annotation.JsonInclude; import com.iemr.flw.masterEnum.FormType; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -65,4 +66,9 @@ public class DynamicFormDTO { @Valid private List sections = new ArrayList<>(); + + /** Every version's full definition, oldest first — populated only by getAllForms (the top-level + * fields above stay the latest version); omitted from the JSON elsewhere and ignored on write. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private List versions; } diff --git a/src/main/java/com/iemr/flw/dto/iemr/FormResponseDTO.java b/src/main/java/com/iemr/flw/dto/iemr/FormResponseDTO.java index efc74085b..3fa5d7a1d 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/FormResponseDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/FormResponseDTO.java @@ -47,6 +47,8 @@ public class FormResponseDTO { private Long beneficiaryId; private Long formId; private Long versionId; + /** Version number of the form version this response was captured against (versionId), not necessarily the latest. */ + private Integer versionNumber; private Long officerId; private String status; private String createdBy; diff --git a/src/main/java/com/iemr/flw/dto/iemr/LatestFormVersionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/LatestFormVersionDTO.java new file mode 100644 index 000000000..ee1d197e5 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/iemr/LatestFormVersionDTO.java @@ -0,0 +1,42 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.dto.iemr; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Latest version summary of a dynamic form: identifies the form and its current version number. + * + * @author Piramal Swasthya + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LatestFormVersionDTO { + private Long formId; + private String formUuid; + private String formName; + private Long versionId; + private Integer currentVersionNumber; +} diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java index 3c2b14409..c1d969de6 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java @@ -54,6 +54,9 @@ public class QuestionOptionDTO { private String optionValueHindi; + /** Optional on input — derived from formType + optionValue when blank. */ + private String optionUuid; + @NotNull(message = "displayOrder is required") private Integer displayOrder; diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionResponseDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionResponseDTO.java index 1bcff9c96..f4002ddfa 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionResponseDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionResponseDTO.java @@ -43,8 +43,13 @@ public class QuestionResponseDTO { private Long questionResponseId; private Long questionId; + private String questionUuid; /** Populated for RADIO, DROPDOWN, MCQ, CHECKBOX, CHECKBOX_MULTI, and DROPDOWN_MULTI answers. */ private Long optionId; + /** optionValue of the selected option; null when optionId is null. */ + private String optionValue; + /** Stable across form versions; null when optionId is null. */ + private String optionUuid; /** Populated for TEXT, DATE, AUTO_FILL, NUMBER_PICKER answers. */ private String answerText; } diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java new file mode 100644 index 000000000..f025bacba --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java @@ -0,0 +1,14 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticCancelResult { + private boolean success; + private String rawResponseJson; + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java index 9737cba02..47cd3ee30 100644 --- a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java @@ -11,6 +11,16 @@ public interface DiagnosticProvider { DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception; + /** + * Notifies the vendor that this order is being closed out without (or regardless of) a result, + * so it stops tracking/processing it on their end. Only call for an order actually known to the + * vendor (i.e. previously pushed) — calling this for an order that was never pushed is meaningless. + * The raw response is returned (on both success and a provider-level rejection) so the caller can + * persist it; only a transport-level failure (unreachable, timeout) throws instead, since there's + * no response body to return in that case. + */ + DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception; + /** * Lightweight liveness check against the vendor group serving this orderType. Never throws — * returns false on any failure (unreachable, non-2xx, unparsable response, unconfigured URL). diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java index 0aa20d631..754711ce1 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -4,6 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -60,6 +61,12 @@ public class EmrLiteProvider implements DiagnosticProvider { @Value("${diagnostic.emrlite.truenat.ping-url}") private String truenatPingUrl; + @Value("${diagnostic.emrlite.xray.cancel-url}") + private String xrayCancelUrl; + + @Value("${diagnostic.emrlite.truenat.cancel-url}") + private String truenatCancelUrl; + @Autowired @Qualifier("emrLiteRestTemplate") private RestTemplate restTemplate; @@ -158,6 +165,34 @@ public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAss tbPresence, tbConfidence, drugResistancePresence); } + @Override + public DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception { + EmrLiteCancelRequest request = new EmrLiteCancelRequest(order.getExternalOrderId(), reason); + String cancelUrl = DiagnosticOrderType.XRAY_CHEST.name().equals(order.getOrderType()) + ? xrayCancelUrl : truenatCancelUrl; + String responseBody; + try { + responseBody = doPost(cancelUrl, gson.toJson(request)); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.warn("Provider HTTP error on order cancel: externalOrderId={}, status={}", + order.getExternalOrderId(), e.getStatusCode()); + return new DiagnosticCancelResult(false, body, "HTTP " + e.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (envelope == null || !envelope.isSuccess()) { + logger.warn("Provider rejected order cancel: externalOrderId={}, message={}", + order.getExternalOrderId(), envelope != null ? envelope.getMessage() : null); + return new DiagnosticCancelResult(false, responseBody, + envelope != null ? envelope.getMessage() : "empty response"); + } + + logger.info("Order cancelled on provider: externalOrderId={}, reason={}", + order.getExternalOrderId(), reason); + return new DiagnosticCancelResult(true, responseBody, null); + } + @Override public boolean checkHealth(DiagnosticOrderType orderType) { String pingUrl = DiagnosticOrderType.XRAY_CHEST.equals(orderType) ? xrayPingUrl : truenatPingUrl; @@ -236,8 +271,8 @@ private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { DiagnosticOrderStatus topLevelStatus = DiagnosticOrderStatus.fromString(result.getStatus()); if (topLevelStatus == DiagnosticOrderStatus.COMPLETED || topLevelStatus == DiagnosticOrderStatus.FAILED) { // Components contradict a terminal top-level status — only the components map may - // resolve to a terminal state; treat this as still in progress. - return DiagnosticOrderStatus.IN_PROGRESS; + // resolve to a terminal state; treat this as still pending. + return DiagnosticOrderStatus.PENDING; } return topLevelStatus; } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java index 87079a183..16af36ecf 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java @@ -14,10 +14,17 @@ public class EmrLiteRestTemplateConfig { private static final int VENDOR_CALL_TIMEOUT_MS = 20000; + // Result responses carry document assets inline as base64, so reads get more headroom than connects. + private static final int VENDOR_READ_TIMEOUT_MS = 60000; + private static final int PING_TIMEOUT_MS = 5000; + // Timeouts are required, not optional: order pushes run while holding the per-beneficiary+orderType + // order lock (see DiagnosticOrderServiceImpl), so a hung vendor call without them would hold that lock forever. @Bean public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(VENDOR_CALL_TIMEOUT_MS); + factory.setReadTimeout(VENDOR_READ_TIMEOUT_MS); RestTemplate restTemplate = new RestTemplate(factory); restTemplate.getInterceptors().add(new EmrLiteAuthInterceptor(emrLiteTokenManager)); return restTemplate; @@ -28,8 +35,8 @@ public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) @Bean public RestTemplate emrLitePingRestTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(VENDOR_CALL_TIMEOUT_MS); - factory.setReadTimeout(VENDOR_CALL_TIMEOUT_MS); + factory.setConnectTimeout(PING_TIMEOUT_MS); + factory.setReadTimeout(PING_TIMEOUT_MS); return new RestTemplate(factory); } } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java new file mode 100644 index 000000000..51f8320da --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteCancelRequest { + private String externalOrderId; + private String reason; +} diff --git a/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java b/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java index 551e93a6d..9f7abac5b 100644 --- a/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java +++ b/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java @@ -48,6 +48,7 @@ public interface DynamicFormMapper { @Mapping(target = "sections", ignore = true) @Mapping(target = "versionNumber", ignore = true) + @Mapping(target = "versions", ignore = true) DynamicFormDTO toDto(DynamicForm entity); FormVersionDTO toDto(FormVersion entity); diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java index 9a0a8ec23..ce444fcb0 100644 --- a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -5,13 +5,14 @@ public enum DiagnosticOrderStatus { IN_PROGRESS, COMPLETED, FAILED, - CANCELLED, - EXPIRED, - REFUSED, + CLOSED, MANUAL_ENTRY; public static DiagnosticOrderStatus fromString(String value) { if (value == null) return PENDING; + // IN_PROGRESS is kept only as a legacy/historical value (old rows, backward-compat reporting + // queries) — it is never freshly assigned. A vendor reporting "IN_PROGRESS" is just PENDING. + if (IN_PROGRESS.name().equalsIgnoreCase(value)) return PENDING; for (DiagnosticOrderStatus status : values()) { if (status.name().equalsIgnoreCase(value)) { return status; diff --git a/src/main/java/com/iemr/flw/masterEnum/ValidationType.java b/src/main/java/com/iemr/flw/masterEnum/ValidationType.java index 931e24699..ed7891588 100644 --- a/src/main/java/com/iemr/flw/masterEnum/ValidationType.java +++ b/src/main/java/com/iemr/flw/masterEnum/ValidationType.java @@ -38,5 +38,8 @@ public enum ValidationType { REGEX, /** Field becomes mandatory when another field equals a specific value. * validationParam format: "QUESTION_UUID=OPTION_VALUE" e.g. "Q-GENDER=FEMALE". */ - MANDATORY_IF + MANDATORY_IF, + /** Display unit for a numeric answer. validationParam = unit label e.g. "sq. m.". + * Informational only — not a rule the answer is checked against. */ + UNIT } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java b/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java index d37789d11..498bc3720 100644 --- a/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java +++ b/src/main/java/com/iemr/flw/repo/identity/BeneficiaryRepo.java @@ -47,7 +47,11 @@ Page getBenDataWithinDates( @Query(value = " SELECT t FROM RMNCHMBeneficiaryaddress t WHERE t.createdBy = :userName ") Page getBenDataByUser(@Param("userName") String userName, Pageable pageable); - @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benAddressId = :addressID") + // BenAddressId can have more than one mapping row (confirmed live: address-ID collisions + // between old/test registrations and real ones). Order by PK desc, same "latest wins" + // convention already used for findByBenRegIdFromMapping, so callers picking .get(0) get + // the newest mapping deterministically instead of whichever row the DB returns first. + @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benAddressId = :addressID ORDER BY t.benMapId DESC") List getByAddressID(@Param("addressID") BigInteger addressID); @Query(value = " SELECT t FROM RMNCHMBeneficiarymapping t WHERE t.benRegId = :BenRegId") @@ -80,7 +84,11 @@ Page getBenDataWithinDates( @Query(value = " SELECT t.beneficiaryID FROM RMNCHMBeneficiaryregidmapping t WHERE t.benRegId = :benRegID ") BigInteger getBenIdFromRegID(@Param("benRegID") Long benRegID); - @Query(value = " SELECT t FROM RMNCHBeneficiaryDetailsRmnch t WHERE t.BenRegId =:benRegID ") + // BenRegId has no unique constraint on i_beneficiarydetails_rmnch — a beneficiary can have + // more than one row (confirmed live: duplicates, some created well after the original with + // a household link the older row never got). Order by PK desc so callers picking .get(0) + // get the latest row, not whichever one the DB happens to return first. + @Query(value = " SELECT t FROM RMNCHBeneficiaryDetailsRmnch t WHERE t.BenRegId =:benRegID ORDER BY t.beneficiaryDetails_RmnchId DESC ") List getDetailsByRegID(@Param("benRegID") Long benRegID); @Query(value = " SELECT t FROM RMNCHBornBirthDetails t WHERE t.BenRegId =:benRegID ") @@ -99,6 +107,13 @@ Page getBenDataWithinDates( @Query(nativeQuery = true, value = " SELECT HealthID,HealthIdNumber,isNewAbha FROM db_iemr.t_healthid WHERE HealthIdNumber = :healthIdNumber ") ArrayList getBenHealthDetails(@Param("healthIdNumber") String healthIdNumber); + // Same rows FHIR-API getBenhealthID reads, so the worklist can fill healthId/healthIdNumber without an HTTP call + @Query(nativeQuery = true, value = " SELECT HealthID, HealthIdNumber FROM db_iemr.m_benhealthidmapping WHERE BeneficiaryRegID = :benRegId ") + List getHealthIdMappingRows(@Param("benRegId") Long benRegId); + + @Query(nativeQuery = true, value = " SELECT HealthId FROM db_iemr.t_healthid WHERE HealthIdNumber = :healthIdNumber ") + List getHealthIdByNumber(@Param("healthIdNumber") String healthIdNumber); + // benRegId has no unique constraint on i_beneficiarymapping — a beneficiary can have // more than one mapping row, so this must return a list, not assume a single result. @Query("SELECT b FROM RMNCHMBeneficiarymapping b WHERE b.benRegId = :benRegId ORDER BY b.benMapId DESC") diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index fd7c72892..36ecc6420 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.sql.Timestamp; import java.util.List; import java.util.Optional; @@ -16,11 +17,8 @@ public interface DiagnosticOrderRepo extends JpaRepository findByExternalOrderId(String externalOrderId); - @Query("SELECT o FROM DiagnosticOrder o WHERE o.beneficiaryId = :beneficiaryId AND o.visitCode = :visitCode " + - "AND o.orderType = :orderType AND o.deleted = false") - Optional findByBeneficiaryIdAndVisitCodeAndOrderType(@Param("beneficiaryId") Long beneficiaryId, - @Param("visitCode") Long visitCode, - @Param("orderType") String orderType); + Optional findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + Long beneficiaryId, Long visitCode, String orderType); Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( Long beneficiaryId, String orderType); @@ -40,14 +38,24 @@ Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrd "ORDER BY o.lastPolledAt ASC NULLS FIRST") List findTrueNatDueForPoll(); - @Query("SELECT o FROM DiagnosticOrder o WHERE o.status IN ('PENDING', 'IN_PROGRESS') AND o.deleted = false") - List findAllOutstandingOrders(); + // The (disabled) daily close only touches orders created within [dayStart, dayEnd) — i.e. today's. + @Query("SELECT o FROM DiagnosticOrder o WHERE o.status IN ('PENDING', 'IN_PROGRESS') AND o.deleted = false " + + "AND o.createdDate >= :dayStart AND o.createdDate < :dayEnd") + List findAllOutstandingOrders(@Param("dayStart") Timestamp dayStart, @Param("dayEnd") Timestamp dayEnd); @Transactional @Modifying @Query("UPDATE DiagnosticOrder o SET o.vanSerialNo = o.id WHERE o.id = :id") void updateVanSerialNo(@Param("id") Long id); + // A poll that changed nothing only records when it ran (and the retry count), leaving processed, + // modified_by and the rest of the row alone: no re-upload to central, no change-log line. + @Transactional + @Modifying + @Query("UPDATE DiagnosticOrder o SET o.lastPolledAt = :polledAt, o.retryCount = :retryCount WHERE o.id = :id") + int recordUnchangedPoll(@Param("id") Long id, @Param("polledAt") Timestamp polledAt, + @Param("retryCount") Integer retryCount); + // Beneficiary-status-summary queries, filtered the same way as // FormResponseRepo.findBeneficiaryIdsByFormIdAndStatusFiltered - null-safe optional // villageId/providerServiceMapId via a correlated BenFlowStatus subquery. @@ -55,7 +63,7 @@ Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrd // beneficiary with an old terminal order and a new in-flight retest of the same orderType // is bucketed only by the retest, not both. @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status NOT IN ('COMPLETED', 'EXPIRED', 'FAILED', 'REFUSED', 'MANUAL_ENTRY') " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + @@ -75,42 +83,44 @@ List findBeneficiaryIdsCompleted(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'EXPIRED' " + + "AND o.status = 'FAILED' " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsPollingTimedOut(@Param("orderType") String orderType, + List findBeneficiaryIdsFailed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'FAILED' " + + "AND o.status IN ('CLOSED', 'CANCELLED', 'EXPIRED', 'REFUSED') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsFailed(@Param("orderType") String orderType, + List findBeneficiaryIdsClosed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'REFUSED' " + + "AND o.status = 'MANUAL_ENTRY' " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsRefused(@Param("orderType") String orderType, + List findBeneficiaryIdsAwaitingManualEntry(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'MANUAL_ENTRY' " + + // The five queries above in one pass: beneficiaryId + status of each beneficiary's latest order, + // same filters, for the caller to bucket by status. + @Query("SELECT o.beneficiaryId, o.status FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED', 'CLOSED', 'CANCELLED', 'EXPIRED', 'REFUSED', 'MANUAL_ENTRY') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsAwaitingManualEntry(@Param("orderType") String orderType, + List findLatestOrderStatusByBeneficiary(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java new file mode 100644 index 000000000..9f993076b --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java @@ -0,0 +1,34 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Repository for applied form-structure migration records. + * Id type is the migrationId String itself — existsById/save need no custom methods. + */ +@Repository +public interface DynamicFormMigrationLogRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java index f389b5dec..c2abe9a0c 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java @@ -41,6 +41,8 @@ public interface FormResponseRepo extends JpaRepository { List findByBeneficiaryIdAndFormId(Long beneficiaryId, Long formId); + List findByBeneficiaryIdAndVersionId(Long beneficiaryId, Long versionId); + @Query("SELECT r FROM FormResponse r WHERE r.formId = :formId AND r.status IN :statuses") List findByFormIdAndStatusIn(@Param("formId") Long formId, @Param("statuses") List statuses); diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java index 9f135959b..66192716c 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java @@ -24,12 +24,14 @@ import com.iemr.flw.domain.iemr.FormSection; import com.iemr.flw.masterEnum.SectionPhase; 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 java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for form sections. @@ -40,6 +42,20 @@ public interface FormSectionRepo extends JpaRepository { List findByFormVersion_VersionIdOrderByDisplayOrderAsc(Long versionId); + /** Read-path variant excluding removed (isActive=false) sections. */ + List findByFormVersion_VersionIdAndIsActiveTrueOrderByDisplayOrderAsc(Long versionId); + + Optional findByFormVersion_VersionIdAndSectionUuid(Long versionId, String sectionUuid); + + Optional findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(Long versionId); + + /** Shifts displayOrder by delta for every section in [from, to] within a version — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE FormSection s SET s.displayOrder = s.displayOrder + :delta " + + "WHERE s.formVersion.versionId = :versionId AND s.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("versionId") Long versionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + @Query("SELECT fs.formVersion.versionId, COUNT(fs) FROM FormSection fs " + "WHERE fs.formVersion.versionId IN :versionIds " + "AND fs.sectionPhase = :sectionPhase " + diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormVersionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormVersionRepo.java index 1d8583aea..e7a8c8ecd 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormVersionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormVersionRepo.java @@ -22,7 +22,9 @@ package com.iemr.flw.repo.iemr; import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.dto.iemr.LatestFormVersionDTO; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -43,4 +45,10 @@ public interface FormVersionRepo extends JpaRepository { List findByDynamicForm_FormIdOrderByVersionNumberAsc(Long formId); Optional findByDynamicForm_FormUuidAndIsLatest(String formUuid, boolean b); + + @Query("SELECT new com.iemr.flw.dto.iemr.LatestFormVersionDTO(f.formId, f.formUuid, f.formName, v.versionId, v.versionNumber) " + + "FROM FormVersion v JOIN v.dynamicForm f " + + "WHERE v.isLatest = true AND f.isActive = true " + + "ORDER BY f.formId ASC") + List findLatestVersionOfActiveForms(); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java index 633418894..7ec5cad52 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java @@ -29,6 +29,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for option conditions. @@ -38,6 +39,13 @@ public interface OptionConditionRepo extends JpaRepository findByQuestionOption_OptionId(Long optionId); + /** No surrogate natural key on conditions — matched by (actionType, target) within an option. */ + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + Long optionId, String actionType, String targetQuestionUuid); + + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + Long optionId, String actionType, String targetSectionUuid); + @Query("SELECT oc.targetQuestion.questionId FROM OptionCondition oc " + "WHERE oc.questionOption.sectionQuestion.formSection.formVersion.versionId = :versionId " + "AND oc.targetQuestion IS NOT NULL") @@ -47,9 +55,11 @@ public interface OptionConditionRepo extends JpaRepository findByOptionIds(@Param("optionIds") Collection optionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java index f75120551..7b5b3418c 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.QuestionOption; 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 java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question options. @@ -38,11 +40,30 @@ public interface QuestionOptionRepo extends JpaRepository List findBySectionQuestion_QuestionIdOrderByDisplayOrderAsc(Long questionId); + Optional findBySectionQuestion_QuestionIdAndOptionValue(Long questionId, String optionValue); + + Optional findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(Long questionId); + + /** Shifts displayOrder by delta for every option in [from, to] within a question — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE QuestionOption o SET o.displayOrder = o.displayOrder + :delta " + + "WHERE o.sectionQuestion.questionId = :questionId AND o.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("questionId") Long questionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all options for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. + * Excludes removed (isActive=false) options — used only by read paths; reconciliation matching + * uses the natural-key finder above, which must see inactive rows too. */ @Query("SELECT o FROM QuestionOption o JOIN FETCH o.sectionQuestion " - + "WHERE o.sectionQuestion.questionId IN :questionIds ORDER BY o.displayOrder ASC") + + "WHERE o.sectionQuestion.questionId IN :questionIds AND o.isActive = true ORDER BY o.displayOrder ASC") List findByQuestionIdsOrderByDisplayOrderAsc(@Param("questionIds") Collection questionIds); + + /** All options (every form, every version, inactive included) still missing an optionUuid — used by the V009 backfill. */ + @Query("SELECT o FROM QuestionOption o JOIN FETCH o.sectionQuestion q JOIN FETCH q.formSection s " + + "JOIN FETCH s.formVersion v JOIN FETCH v.dynamicForm " + + "WHERE o.optionUuid IS NULL OR o.optionUuid = ''") + List findAllMissingOptionUuid(); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java index d1cf41876..17e1ea616 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java @@ -22,6 +22,7 @@ package com.iemr.flw.repo.iemr; import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.masterEnum.ValidationType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -29,6 +30,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question validation rules. @@ -38,11 +40,17 @@ public interface QuestionValidationRepo extends JpaRepository findBySectionQuestion_QuestionId(Long questionId); + /** No surrogate natural key on validations — matched by the (type, param) content tuple within a question. */ + Optional findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + Long questionId, ValidationType validationType, String validationParam); + /** * Loads all validations for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. + * Excludes removed (isActive=false) validations — used only by read paths; reconciliation matching + * uses the natural-key finder above, which must see inactive rows too. */ @Query("SELECT v FROM QuestionValidation v JOIN FETCH v.sectionQuestion " - + "WHERE v.sectionQuestion.questionId IN :questionIds") + + "WHERE v.sectionQuestion.questionId IN :questionIds AND v.isActive = true") List findByQuestionIds(@Param("questionIds") Collection questionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java index 6a117e968..520993666 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.SectionQuestion; 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 java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for section questions. @@ -38,11 +40,27 @@ public interface SectionQuestionRepo extends JpaRepository findByFormSection_SectionIdOrderByDisplayOrderAsc(Long sectionId); + Optional findByFormSection_SectionIdAndQuestionUuid(Long sectionId, String questionUuid); + + /** Version-scoped lookup — a condition's target question may live in a different section of the same version. */ + Optional findByFormSection_FormVersion_VersionIdAndQuestionUuid(Long versionId, String questionUuid); + + Optional findTopByFormSection_SectionIdOrderByDisplayOrderDesc(Long sectionId); + + /** Shifts displayOrder by delta for every question in [from, to] within a section — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE SectionQuestion q SET q.displayOrder = q.displayOrder + :delta " + + "WHERE q.formSection.sectionId = :sectionId AND q.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("sectionId") Long sectionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all questions for a set of sections in one query. * JOIN FETCH ensures formSection is hydrated so callers can group by sectionId without extra queries. + * Excludes unlinked (isActive=false) questions — used only by read paths; reconciliation matching + * uses the natural-key finders above, which must see inactive rows too. */ @Query("SELECT q FROM SectionQuestion q JOIN FETCH q.formSection " - + "WHERE q.formSection.sectionId IN :sectionIds ORDER BY q.displayOrder ASC") + + "WHERE q.formSection.sectionId IN :sectionIds AND q.isActive = true ORDER BY q.displayOrder ASC") List findBySectionIdsOrderByDisplayOrderAsc(@Param("sectionIds") Collection sectionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/StopTBDiagnosticsRepo.java b/src/main/java/com/iemr/flw/repo/iemr/StopTBDiagnosticsRepo.java index de3fa9096..19bb9acfa 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/StopTBDiagnosticsRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/StopTBDiagnosticsRepo.java @@ -4,6 +4,8 @@ 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.jpa.repository.QueryHints; +import jakarta.persistence.QueryHint; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +21,8 @@ public interface StopTBDiagnosticsRepo extends JpaRepository findAllByProviderServiceMapID(@Param("psmId") Integer psmId); + // Read-only: getAll only reads these rows, so Hibernate keeps no change-tracking snapshot per row + @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true")) @Query("SELECT d FROM StopTBDiagnostics d WHERE d.benRegID IN " + "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId) " + "AND d.deleted = false ORDER BY d.visitDate DESC") diff --git a/src/main/java/com/iemr/flw/repo/iemr/StopTBGeneralExaminationRepo.java b/src/main/java/com/iemr/flw/repo/iemr/StopTBGeneralExaminationRepo.java index 944e99a65..798fcaf85 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/StopTBGeneralExaminationRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/StopTBGeneralExaminationRepo.java @@ -5,6 +5,8 @@ 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.jpa.repository.QueryHints; +import jakarta.persistence.QueryHint; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +34,8 @@ StopTBGeneralExamination findByBeneficiaryRegIDAndVisitCode(@Param("beneficiaryR @Query("SELECT e FROM StopTBGeneralExamination e WHERE e.providerServiceMapID = :psmId AND e.deleted = false ORDER BY e.createdDate DESC") List findAllByProviderServiceMapID(@Param("psmId") Integer psmId); + // Read-only: getAll only reads these rows, so Hibernate keeps no change-tracking snapshot per row + @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true")) @Query("SELECT e FROM StopTBGeneralExamination e WHERE e.beneficiaryRegID IN " + "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId) " + "AND e.deleted = false ORDER BY e.createdDate DESC") diff --git a/src/main/java/com/iemr/flw/repo/iemr/StopTBGeneralOpdRepo.java b/src/main/java/com/iemr/flw/repo/iemr/StopTBGeneralOpdRepo.java index 3cb8868f8..d35925a26 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/StopTBGeneralOpdRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/StopTBGeneralOpdRepo.java @@ -5,6 +5,8 @@ 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.jpa.repository.QueryHints; +import jakarta.persistence.QueryHint; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +32,8 @@ public interface StopTBGeneralOpdRepo extends JpaRepository findAllByProviderServiceMapID(@Param("psmId") Integer psmId); + // Read-only: getAll only reads these rows, so Hibernate keeps no change-tracking snapshot per row + @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true")) @Query("SELECT o FROM StopTBGeneralOpd o WHERE o.benRegID IN " + "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId) " + "AND o.deleted = false ORDER BY o.createdDate DESC") diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBScreeningRepo.java b/src/main/java/com/iemr/flw/repo/iemr/TBScreeningRepo.java index f656dc91d..cfb0665db 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/TBScreeningRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/TBScreeningRepo.java @@ -5,6 +5,8 @@ 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.jpa.repository.QueryHints; +import jakarta.persistence.QueryHint; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -37,6 +39,8 @@ public interface TBScreeningRepo extends JpaRepository { @Query("SELECT tbs FROM TBScreening tbs WHERE tbs.providerServiceMapID = :psmId AND tbs.deleted = false ORDER BY tbs.visitDate DESC") List findAllByProviderServiceMapID(@Param("psmId") Integer psmId); + // Read-only: getAll only reads these rows, so Hibernate keeps no change-tracking snapshot per row + @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true")) @Query("SELECT tbs FROM TBScreening tbs WHERE tbs.benRegID IN " + "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId) " + "AND tbs.deleted = false ORDER BY tbs.visitDate DESC") diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java b/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java index 3df494fa4..277d21fd6 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java @@ -29,8 +29,11 @@ TBSuspected getByUserIdAndBenIdAndVisitCode(@Param("benId") Long benId, @Param(" @Query("SELECT tbs FROM TBSuspected tbs WHERE tbs.userId = :userId and tbs.visitDate >= :fromDate and tbs.visitDate <= :toDate") List getByUserId(@Param("userId") Integer userId, @Param("fromDate") Timestamp fromDate, @Param("toDate") Timestamp toDate); - @Query("SELECT tbs FROM TBSuspected tbs WHERE tbs.benId IN " + - "(SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId)") + // Same query as before in SQL, with an optimizer hint so the village subquery reads + // idx_flow_psm_village_reg instead of the PSM-only index (ignored if that index is missing). + @Query(nativeQuery = true, value = "SELECT t.* FROM db_iemr.tb_suspected t WHERE t.ben_id IN " + + "(SELECT /*+ INDEX(b idx_flow_psm_village_reg) */ b.beneficiary_id FROM db_iemr.i_ben_flow_outreach b " + + "WHERE b.providerServiceMapID = :psmId AND b.villageID = :villageId)") List getByProviderServiceMapIdAndVillageId(@Param("psmId") Integer psmId, @Param("villageId") Integer villageId); @Transactional diff --git a/src/main/java/com/iemr/flw/repo/iemr/UserServiceRoleRepo.java b/src/main/java/com/iemr/flw/repo/iemr/UserServiceRoleRepo.java index 354088892..d2cdc2f46 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/UserServiceRoleRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/UserServiceRoleRepo.java @@ -36,10 +36,47 @@ public interface UserServiceRoleRepo extends JpaRepository getUserRolesByIds(@Param("userIds") List userIds); + @Query(value = "SELECT d.DistrictID, d.DistrictName FROM m_districtblock db " + + "JOIN m_district d ON d.DistrictID = db.DistrictID " + + "WHERE db.BlockID = :blockId LIMIT 1", nativeQuery = true) + List getDistrictByBlockId(@Param("blockId") Integer blockId); + + // Stop TB / Nikshay — additive only. Reads m_userservicerolemapping directly + // (NOT the shared v_userservicerolemapping view) so the view stays untouched + // and no other service line's query is affected. Returns nothing for any + // user whose rows don't have NikshayTUID set (i.e. every non-Stop-TB user). + // NikshayTUID/NikshayFacilityID are TEXT columns holding a comma-joined list + // of IDs (e.g. "12,45,78"), not a single ID. Name resolution against + // m_nikshay_tu/m_nikshay_facility is done in Java (see UserServiceImpl) via + // an indexed IN(...) lookup on their primary keys, instead of joining here + // with FIND_IN_SET — FIND_IN_SET can't use an index on either side, so it + // forces a full scan of the master table per call (measured: ~60s against + // m_nikshay_facility's 300k+ rows for a user with ~100 mapped facilities). + @Query(value = "SELECT usrm.NikshayTUID, usrm.NikshayFacilityID, usrm.DistrictID " + + "FROM m_userservicerolemapping usrm " + + "WHERE usrm.UserID = :userId AND usrm.ProviderServiceMapID = :providerServiceMapId " + + "AND usrm.Deleted = false AND usrm.NikshayTUID IS NOT NULL", + nativeQuery = true) + List getNikshayMappingRows(@Param("userId") Integer userId, + @Param("providerServiceMapId") Integer providerServiceMapId); + + // Every active mapping row's service and village list, for the STOP-TB changes feed's scope check. + @Query(value = "SELECT usrm.ProviderServiceMapID, usrm.Villageid FROM m_userservicerolemapping usrm " + + "WHERE usrm.UserID = :userId AND usrm.Deleted = false", nativeQuery = true) + List getServiceVillageRows(@Param("userId") Integer userId); + + @Query(value = "SELECT NikshayTUID, TUName FROM m_nikshay_tu WHERE NikshayTUID IN (:ids)", nativeQuery = true) + List findNikshayTuNames(@Param("ids") java.util.Collection ids); + + @Query(value = "SELECT NikshayFacilityID, FacilityName FROM m_nikshay_facility WHERE NikshayFacilityID IN (:ids)", nativeQuery = true) + List findNikshayFacilityNames(@Param("ids") java.util.Collection ids); + + // DistrictID on m_userservicerolemapping is a Nikshay district ID, not an + // AMRIT one (see setWorkLocationObject in Admin-UI) - resolving its name + // against m_nikshay_district (the same ID space) is safe, unlike joining it + // against AMRIT's own m_district, which would silently match whatever AMRIT + // district happens to share that same numeric ID by coincidence. + @Query(value = "SELECT DistrictName FROM m_nikshay_district WHERE NikshayDistrictID = :id", nativeQuery = true) + String findNikshayDistrictName(@Param("id") Integer id); } diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java new file mode 100644 index 000000000..ae4de9c6e --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Applies one {@link FormStructureMigration} and records it as applied, as a single atomic unit. + * Kept as its own bean (rather than a method on {@link DynamicFormMigrationRunner}) so the + * {@code @Transactional} proxy is actually honored — a runner calling this method on itself would + * bypass Spring AOP's self-invocation limitation. + */ +@Service +@RequiredArgsConstructor +public class DynamicFormMigrationApplier { + + private final DynamicFormReconciliationService reconciliationService; + private final DynamicFormMigrationLogRepo migrationLogRepo; + + @Transactional + public void apply(FormStructureMigration migration) { + migration.apply(reconciliationService); + migrationLogRepo.save(new DynamicFormMigrationLog(migration.migrationId(), null)); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java new file mode 100644 index 000000000..2071b61ed --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java @@ -0,0 +1,73 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; + +/** + * Applies every {@link FormStructureMigration} bean, in {@link FormStructureMigration#migrationId()} + * order, skipping ones already recorded in {@code t_dynamic_form_migration_log}. + * + * Runs as an {@link ApplicationRunner} rather than another {@code @PostConstruct} specifically so it + * always runs after the 6 form seeders: Spring Boot invokes ApplicationRunners only once the whole + * application context has finished refreshing, which is strictly after every bean's + * {@code @PostConstruct} (the seeders included) has already completed — no {@code @Order} + * coordination needed between the two mechanisms. + * + * A migration that throws propagates out of this loop and aborts startup — a broken migration + * should fail loudly, not be silently skipped, and later migrations shouldn't apply out of order + * behind a failed one. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DynamicFormMigrationRunner implements ApplicationRunner { + + private final List migrations; + private final DynamicFormMigrationLogRepo migrationLogRepo; + private final DynamicFormMigrationApplier applier; + + @Override + public void run(ApplicationArguments args) { + migrations.stream() + .sorted(Comparator.comparing(FormStructureMigration::migrationId)) + .forEach(this::applyIfNeeded); + } + + private void applyIfNeeded(FormStructureMigration migration) { + String id = migration.migrationId(); + if (migrationLogRepo.existsById(id)) { + log.info("Form structure migration '{}' already applied — skipping.", id); + return; + } + applier.apply(migration); + log.info("Form structure migration '{}' applied.", id); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java new file mode 100644 index 000000000..969bb04f3 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java @@ -0,0 +1,78 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.service.DynamicFormReconciliationService; + +/** + * One incremental, one-off structural change to a form that has already been created by one of the + * {@code com.iemr.flw.seeder} classes — e.g. "add this new question to that existing section", or + * "add this new option to that existing question". {@code DynamicFormMigrationRunner} discovers + * every Spring bean implementing this interface, sorts them by {@link #migrationId()}, and applies + * each exactly once, recording it in {@code t_dynamic_form_migration_log} so it is skipped on + * every later restart. A migration should never be edited after it has shipped — write a new one + * instead — since the log only records "has this id run", not what it actually did. + * + * Naming convention: {@code V_ShortDescription}, e.g. {@code V001_AddSideEffectsQuestion}, + * zero-padded so lexicographic sort matches numeric order. Give each class a javadoc stating the + * business reason for the change — that, plus the class itself, is the permanent changelog. + * + * Example: + *
{@code
+ * @Component
+ * public class V001_AddSideEffectsQuestionToTbCounselling implements FormStructureMigration {
+ *
+ *     @Override
+ *     public String migrationId() {
+ *         return "V001";
+ *     }
+ *
+ *     @Override
+ *     public void apply(DynamicFormReconciliationService svc) {
+ *         FormVersion version = svc.latestVersion("TB_COUNSELLING")
+ *                 .orElseThrow(() -> new IllegalStateException(
+ *                         "TB_COUNSELLING not found — must run after TbCounsellingFormSeeder"));
+ *         FormSection section = svc.section(version, "TB_SEC_B")
+ *                 .orElseThrow(() -> new IllegalStateException("Section TB_SEC_B not found"));
+ *
+ *         SectionQuestionDTO question = new SectionQuestionDTO();
+ *         question.setQuestionUuid("TB_B_Q7");
+ *         question.setQuestionText("Side effects reported?");
+ *         question.setQuestionType(QuestionType.RADIO);
+ *         question.setOptions(List.of(yesOption, noOption));
+ *         svc.ensureQuestion(section, question);
+ *     }
+ * }
+ * }
+ */ +public interface FormStructureMigration { + + /** Stable id, e.g. "V001" — the migration-log primary key and the sort key for apply order. Never change it once shipped. */ + String migrationId(); + + /** + * Applies this migration's change(s) via the reconciliation service's ensureX methods. + * Should remain safe to re-run (ensureX no-ops on rows that already exist) even though the + * runner normally calls this only once per migrationId, per the tracking log. + */ + void apply(DynamicFormReconciliationService reconciliationService); +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java b/src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java new file mode 100644 index 000000000..78c7cd645 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java @@ -0,0 +1,108 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.service.DynamicFormDefinitionService; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Clones the seeded version 1 of both contact tracing forms — Community Contact Tracing and + * Occupational Contact Tracing — into a new version 2, so the later structural changes to them are + * applied to version 2 only and version 1 stays exactly as the seeders created it: + * COMMUNITY_CONTACT_TRACING (V002, V003, V006, V007, V008) and OCCUPATION_CONTACT_TRACING (V002, V004, + * V005). Responses keep the versionId they were submitted against; new responses and the app use the + * latest version. + * + * Id "V001_1" sorts between V001 and V002, so this runs before the first migration that touches + * either form — every migration resolves a form via {@code latestVersion}, which is version 2 from + * here on. + * + * Uses {@link DynamicFormDefinitionService#updateForm}, which copies every section, question, option, + * validation and condition into a new version and marks the current one isLatest=false. All ids are + * cleared from the version-1 DTO first: the mapper copies them onto the new entities, and saving an + * entity with an existing id would move that version-1 row into version 2 instead of copying it. + * Conditions are re-wired by targetQuestionUuid/targetSectionUuid, which are kept. + */ +@Component +@RequiredArgsConstructor +public class V001_1_CreateContactTracingVersion2 implements FormStructureMigration { + + private static final List FORM_UUIDS = List.of( + "COMMUNITY_CONTACT_TRACING", + "OCCUPATION_CONTACT_TRACING"); + + private final DynamicFormDefinitionService definitionService; + + @Override + public String migrationId() { + return "V001_1"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FORM_UUIDS.forEach(formUuid -> createVersion2(svc, formUuid)); + } + + private void createVersion2(DynamicFormReconciliationService svc, String formUuid) { + FormVersion latest = svc.latestVersion(formUuid) + .orElseThrow(() -> new IllegalStateException(formUuid + " not found — must run after its seeder")); + if (latest.getVersionNumber() > 1) { + return; + } + + Long formId = latest.getDynamicForm().getFormId(); + DynamicFormDTO version1 = definitionService.getFormDefinition(formId); + clearIds(version1); + definitionService.updateForm(formId, version1); + } + + private void clearIds(DynamicFormDTO form) { + for (FormSectionDTO section : form.getSections()) { + section.setSectionId(null); + for (SectionQuestionDTO question : section.getQuestions()) { + question.setQuestionId(null); + for (QuestionValidationDTO validation : question.getValidations()) { + validation.setValidationId(null); + } + for (QuestionOptionDTO option : question.getOptions()) { + option.setOptionId(null); + for (OptionConditionDTO condition : option.getConditions()) { + condition.setConditionId(null); + condition.setTargetQuestionId(null); + condition.setTargetSectionId(null); + } + } + } + } + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java new file mode 100644 index 000000000..6fc90ff6e --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java @@ -0,0 +1,104 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Replaces the TPT Follow Up form's "Adverse Drug Reactions" (TFU_ADR) option list with the + * clinically revised set of symptoms, per the updated ADR checklist supplied for this form. + * + * 7 of the 24 existing options already match the new list exactly (same wording) and are left + * untouched by {@code ensureOption}'s natural-key match: NAUSEA, VOMITING, ABDOMINAL_PAIN, + * DARK_COLOUR_URINE, ITCHING, TINGLING_BURNING_NUMBNESS_HANDS_FEET, FLU_LIKE_SYNDROME. The other + * 17 old options are no longer part of the revised list and are soft-removed (never hard-deleted — + * historical responses may reference them); the 13 new options are added. + */ +@Component +public class V001_ReplaceTfuAdrOptions implements FormStructureMigration { + + private static final List REMOVED_OPTION_VALUES = List.of( + "UNKNOWN", + "FLU_LIKE_SYNDROME_WITH_DIZZINESS_HEADACHE", + "YELLOWISH_DISCOLORATION_SKIN_EYES", + "DISCOLORATION_BODY_FLUIDS", + "PALE_STOOL", + "SKIN_RASH", + "SLEEPINESS_LETHARGY", + "PERSISTENT_UNFORMED_WATERY_STOOLS", + "MENTAL_CHANGES_SIGNS_BLEEDING", + "CONVULSIONS", + "ANAEMIA", + "ARTHRALGIA", + "DECREASED_APPETITE", + "HYPOTENSION_SYNCOPE", + "CONJUCTIVITIES", + "SHOCK", + "NONE_REPORTED"); + + @Override + public String migrationId() { + return "V001"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("TPT_FOLLOW_UP") + .orElseThrow(() -> new IllegalStateException( + "TPT_FOLLOW_UP not found — must run after TptFollowUpFormSeeder")); + FormSection section = svc.section(version, "TFU_SEC_2") + .orElseThrow(() -> new IllegalStateException("Section TFU_SEC_2 not found")); + SectionQuestion question = svc.question(section, "TFU_ADR") + .orElseThrow(() -> new IllegalStateException("Question TFU_ADR not found")); + + REMOVED_OPTION_VALUES.forEach(value -> svc.option(question, value) + .ifPresent(opt -> svc.removeOption(opt.getOptionId()))); + + addOption(svc, question, "Yellowness of Skin", "YELLOWNESS_OF_SKIN"); + addOption(svc, question, "Loose motions >4 times in a day", "LOOSE_MOTIONS_GT4_PER_DAY"); + addOption(svc, question, "Rashes", "RASHES"); + addOption(svc, question, "Pain in Joints", "PAIN_IN_JOINTS"); + addOption(svc, question, "Impaired vision: Pain, Blurring of vision, Disturbance in colour vision", "IMPAIRED_VISION"); + addOption(svc, question, "Swelling of face or legs, Less or no urine", "SWELLING_FACE_LEGS_LESS_NO_URINE"); + addOption(svc, question, "Seeing abnormal things, changes of thoughts, suicidal thoughts", "ABNORMAL_THOUGHTS_SUICIDAL"); + addOption(svc, question, "Tiredness, lethargy, headache, giddiness, pale look, palpitations", "TIREDNESS_LETHARGY_GIDDINESS_PALPITATIONS"); + addOption(svc, question, "Ringing in ears, Loss of hearing, dizziness and loss of balance leading to recurrent fall", "TINNITUS_HEARING_LOSS_DIZZINESS_FALL"); + addOption(svc, question, "Slowness of activities, Swelling of face or neck, dis-appropriate weight gain", "SLOWNESS_FACIAL_NECK_SWELLING_WEIGHT_GAIN"); + addOption(svc, question, "Pain and swelling in muscles and Tendons, difficulty in movement", "MUSCLE_TENDON_PAIN_SWELLING"); + addOption(svc, question, "Convulsion", "CONVULSION"); + addOption(svc, question, "Orange and red color of urine, sweat, sputum, saliva or tears", "ORANGE_RED_DISCOLORATION_BODY_FLUIDS"); + } + + private void addOption(DynamicFormReconciliationService svc, SectionQuestion question, String label, String value) { + QuestionOptionDTO dto = new QuestionOptionDTO(); + dto.setOptionLabel(label); + dto.setOptionValue(value); + svc.ensureOption(question, dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java b/src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java new file mode 100644 index 000000000..836c1b1e3 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java @@ -0,0 +1,83 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +/** + * Renames the "No. of contacts" question to "Total No. of contacts" (and its Hindi translation) + * in both the Community Contact Tracing (CCT_NO_OF_CONTACTS) and Occupational Contact Tracing + * (OCT_NO_OF_CONTACTS) forms, per the revised field-label wording supplied for these forms. + * + * Only the label text changes — type, mandatoriness and position are read off the existing row + * and passed back unchanged, since {@code updateQuestion} overwrites all of its scalar fields + * unconditionally. + */ +@Component +public class V002_RenameNoOfContactsQuestion implements FormStructureMigration { + + private static final String NEW_TEXT = "Total No. of contacts"; + private static final String NEW_TEXT_HINDI = "कुल संपर्कों की संख्या"; + + @Override + public String migrationId() { + return "V002"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + renameQuestion(svc, "COMMUNITY_CONTACT_TRACING", "CCT_SEC_1", "CCT_NO_OF_CONTACTS"); + renameQuestion(svc, "OCCUPATION_CONTACT_TRACING", "OCT_SEC_1", "OCT_NO_OF_CONTACTS"); + } + + private void renameQuestion(DynamicFormReconciliationService svc, String formUuid, String sectionUuid, + String questionUuid) { + FormVersion version = svc.latestVersion(formUuid) + .orElseThrow(() -> new IllegalStateException( + formUuid + " not found — must run after its seeder")); + FormSection section = svc.section(version, sectionUuid) + .orElseThrow(() -> new IllegalStateException("Section " + sectionUuid + " not found")); + SectionQuestion question = svc.question(section, questionUuid) + .orElseThrow(() -> new IllegalStateException("Question " + questionUuid + " not found")); + + if (NEW_TEXT.equals(question.getQuestionText()) && NEW_TEXT_HINDI.equals(question.getQuestionTextHindi())) { + return; + } + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionText(NEW_TEXT); + dto.setQuestionTextHindi(NEW_TEXT_HINDI); + dto.setQuestionType(question.getQuestionType()); + dto.setIsMandatory(question.getIsMandatory()); + dto.setMaxLength(question.getMaxLength()); + dto.setDefaultValue(question.getDefaultValue()); + dto.setContainsPii(question.getContainsPii()); + dto.setDisplayOrder(question.getDisplayOrder()); + + svc.updateQuestion(question.getQuestionId(), dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java b/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java new file mode 100644 index 000000000..ba69d37b4 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java @@ -0,0 +1,76 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +/** + * Changes the Community Contact Tracing form's "Place of exposure" question (CCT_EXPOSURE_SETTING) + * from CHECKBOX_MULTI to DROPDOWN_MULTI, so the 12 place options render as a multi-select dropdown + * instead of a long checkbox list. + * + * Both types submit {@code optionValues} and store one response row per selected option, so + * existing responses remain valid. Options and the OTHER_PLACE → CCT_EXPOSURE_SETTING_OTHER + * conditional rule are untouched. All other scalar fields are read off the existing row and passed + * back unchanged, since {@code updateQuestion} overwrites all of them unconditionally. + */ +@Component +public class V003_ChangeExposureSettingToDropdownMulti implements FormStructureMigration { + + @Override + public String migrationId() { + return "V003"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder")); + FormSection section = svc.section(version, "CCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found")); + SectionQuestion question = svc.question(section, "CCT_EXPOSURE_SETTING") + .orElseThrow(() -> new IllegalStateException("Question CCT_EXPOSURE_SETTING not found")); + + if (question.getQuestionType() == QuestionType.DROPDOWN_MULTI) { + return; + } + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionText(question.getQuestionText()); + dto.setQuestionTextHindi(question.getQuestionTextHindi()); + dto.setQuestionType(QuestionType.DROPDOWN_MULTI); + dto.setIsMandatory(question.getIsMandatory()); + dto.setMaxLength(question.getMaxLength()); + dto.setDefaultValue(question.getDefaultValue()); + dto.setContainsPii(question.getContainsPii()); + dto.setDisplayOrder(question.getDisplayOrder()); + + svc.updateQuestion(question.getQuestionId(), dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java b/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java new file mode 100644 index 000000000..24db1f454 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java @@ -0,0 +1,76 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +/** + * Changes the Occupational Contact Tracing form's "Occupation" question (OCT_OCCUPATION) from RADIO + * to DROPDOWN, so the 11 occupation options render as a single-select dropdown instead of a long + * radio list. + * + * Both types submit a single {@code optionValue} and store one response row, so existing responses + * remain valid. Options and their conditional rules (address questions, OTHER_OCCUPATION → + * OCT_OCCUPATION_OTHER) are untouched. All other scalar fields are read off the existing row and + * passed back unchanged, since {@code updateQuestion} overwrites all of them unconditionally. + */ +@Component +public class V004_ChangeOccupationToDropdown implements FormStructureMigration { + + @Override + public String migrationId() { + return "V004"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("OCCUPATION_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "OCCUPATION_CONTACT_TRACING not found — must run after OccupationalContactTracingFormSeeder")); + FormSection section = svc.section(version, "OCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section OCT_SEC_1 not found")); + SectionQuestion question = svc.question(section, "OCT_OCCUPATION") + .orElseThrow(() -> new IllegalStateException("Question OCT_OCCUPATION not found")); + + if (question.getQuestionType() == QuestionType.DROPDOWN) { + return; + } + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionText(question.getQuestionText()); + dto.setQuestionTextHindi(question.getQuestionTextHindi()); + dto.setQuestionType(QuestionType.DROPDOWN); + dto.setIsMandatory(question.getIsMandatory()); + dto.setMaxLength(question.getMaxLength()); + dto.setDefaultValue(question.getDefaultValue()); + dto.setContainsPii(question.getContainsPii()); + dto.setDisplayOrder(question.getDisplayOrder()); + + svc.updateQuestion(question.getQuestionId(), dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java b/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java new file mode 100644 index 000000000..11a6a3d0c --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java @@ -0,0 +1,115 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.ValidationType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Adds an "Approximate Area of Shared Space" question to the Occupational Contact Tracing form, + * directly after "Type of Space" (OCT_TYPE_OF_SPACE), per the revised criteria for this form: + * enabled once a Type of Space is selected, whole square metres only, accepted range 50–5000. + * + * Mapped to Type of Space as a condition: hidden by default, and each of the 3 OCT_TYPE_OF_SPACE + * options reveals it (SHOW_QUESTION) and makes it mandatory (MANDATORY_IF) — conditions attach to + * options, so "any Type of Space selected" is one condition per option, all targeting this single + * question. Conventions used, since questions carry no dedicated fields for these: + * the helper text is stored in {@code defaultValue}, and the unit as a {@link ValidationType#UNIT} + * validation whose {@code validationParam} is the unit label. + * + * Inserted at display order 7 — {@code ensureQuestion} shifts "Daily duration of contact" to 8 and + * "Remarks" to 9. + */ +@Component +public class V005_AddAreaOfSharedSpaceQuestion implements FormStructureMigration { + + private static final String QUESTION_UUID = "OCT_AREA_OF_SHARED_SPACE"; + + private static final List TYPE_OF_SPACE_VALUES = List.of( + "OPEN_SPACE", + "CLOSED_SPACE_VENTILATED", + "CLOSED_SPACE_NOT_VENTILATED"); + + @Override + public String migrationId() { + return "V005"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("OCCUPATION_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "OCCUPATION_CONTACT_TRACING not found — must run after OccupationalContactTracingFormSeeder")); + FormSection section = svc.section(version, "OCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section OCT_SEC_1 not found")); + SectionQuestion typeOfSpace = svc.question(section, "OCT_TYPE_OF_SPACE") + .orElseThrow(() -> new IllegalStateException("Question OCT_TYPE_OF_SPACE not found")); + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionUuid(QUESTION_UUID); + dto.setQuestionText("Approximate Area of Shared Space"); + dto.setQuestionTextHindi("साझा स्थान का अनुमानित क्षेत्रफल"); + dto.setQuestionType(QuestionType.NUMBER_PICKER); + dto.setDefaultValue("Give estimated measurement of area"); + dto.setIsMandatory(true); + dto.setDisplayOrder(7); + dto.setVisibleByDefault(false); + dto.setOptions(List.of()); + dto.setValidations(List.of()); + SectionQuestion area = svc.ensureQuestion(section, dto); + + svc.ensureValidation(area, validation(ValidationType.REGEX, + "^([5-9][0-9]|[1-9][0-9]{2}|[1-4][0-9]{3}|5000)$", "Enter a value between 50 and 5000.")); + svc.ensureValidation(area, validation(ValidationType.UNIT, "sq. m.", "Value is in sq. m.")); + + for (String value : TYPE_OF_SPACE_VALUES) { + svc.ensureValidation(area, validation(ValidationType.MANDATORY_IF, + "OCT_TYPE_OF_SPACE=" + value, "This field is mandatory")); + + QuestionOption option = svc.option(typeOfSpace, value) + .orElseThrow(() -> new IllegalStateException("Option OCT_TYPE_OF_SPACE=" + value + " not found")); + OptionConditionDTO show = new OptionConditionDTO(); + show.setActionType("SHOW_QUESTION"); + show.setTargetQuestionUuid(QUESTION_UUID); + svc.ensureCondition(option, show, version); + } + } + + private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) { + QuestionValidationDTO v = new QuestionValidationDTO(); + v.setValidationType(type); + v.setValidationParam(param); + v.setErrorMessage(errorMessage); + return v; + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java b/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java new file mode 100644 index 000000000..822ea0867 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java @@ -0,0 +1,172 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.ValidationType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Replaces the Community Contact Tracing form's single, standalone "Type of Space" question + * (CCT_TYPE_OF_SPACE) with one per "Relationship with community contacts" (CCT_RELATIONSHIP) + * option, each followed by its own "Approximate Area of Shared Space", so the exposure setting is + * captured per relationship type: + * + *
+ * CCT_RELATIONSHIP=<R> ─ SHOW_QUESTION ─► CCT_TYPE_OF_SPACE_<R>
+ *                                          └─ each option ─ SHOW_QUESTION ─► CCT_AREA_OF_SHARED_SPACE_<R>
+ * 
+ * + * The old CCT_TYPE_OF_SPACE is soft-removed (never hard-deleted — historical responses may + * reference it). The 14 new questions (a Type of Space / Area pair per relationship) are inserted + * into its slot, all hidden by default and mandatory only when revealed (MANDATORY_IF). Area + * questions follow V006's conventions: helper text in {@code defaultValue}, unit as a + * {@link ValidationType#UNIT} validation, whole square metres 50–5000. + */ +@Component +public class V006_PerRelationshipTypeOfSpace implements FormStructureMigration { + + private static final List RELATIONSHIP_VALUES = List.of( + "NEIGHBOUR", + "FRIEND", + "FELLOW_WORSHIPPER", + "FELLOW_COMMUTER", + "COMMUNITY_GROUP_MEMBER", + "FELLOW_PATIENT", + "OTHER"); + + /** {label, labelHindi, value} — same options as the retired CCT_TYPE_OF_SPACE. */ + private static final List TYPE_OF_SPACE_OPTIONS = List.of( + new String[] {"Open space", "खुला स्थान", "OPEN_SPACE"}, + new String[] {"Closed space with ventilation", "वेंटिलेशन के साथ बंद स्थान", "CLOSED_SPACE_VENTILATED"}, + new String[] {"Closed space with no ventilation", "बिना वेंटिलेशन के बंद स्थान", "CLOSED_SPACE_NOT_VENTILATED"}); + + private static final String AREA_REGEX = "^([5-9][0-9]|[1-9][0-9]{2}|[1-4][0-9]{3}|5000)$"; + private static final String MANDATORY_ERROR = "This field is mandatory"; + + @Override + public String migrationId() { + return "V006"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder")); + FormSection section = svc.section(version, "CCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found")); + SectionQuestion relationship = svc.question(section, "CCT_RELATIONSHIP") + .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found")); + SectionQuestion oldTypeOfSpace = svc.question(section, "CCT_TYPE_OF_SPACE") + .orElseThrow(() -> new IllegalStateException("Question CCT_TYPE_OF_SPACE not found")); + + int base = oldTypeOfSpace.getDisplayOrder(); + if (Boolean.TRUE.equals(oldTypeOfSpace.getIsActive())) { + svc.unlinkQuestion(oldTypeOfSpace.getQuestionId()); + } + + for (int i = 0; i < RELATIONSHIP_VALUES.size(); i++) { + String r = RELATIONSHIP_VALUES.get(i); + String typeOfSpaceUuid = "CCT_TYPE_OF_SPACE_" + r; + String areaUuid = "CCT_AREA_OF_SHARED_SPACE_" + r; + + // Type of Space for this relationship — revealed by CCT_RELATIONSHIP= + SectionQuestion typeOfSpace = svc.ensureQuestion(section, question(typeOfSpaceUuid, + "Type of Space", "स्थान का प्रकार", QuestionType.RADIO, null, base + 1 + 2 * i)); + svc.ensureValidation(typeOfSpace, validation(ValidationType.MANDATORY_IF, + "CCT_RELATIONSHIP=" + r, MANDATORY_ERROR)); + QuestionOption relationshipOption = svc.option(relationship, r) + .orElseThrow(() -> new IllegalStateException("Option CCT_RELATIONSHIP=" + r + " not found")); + svc.ensureCondition(relationshipOption, showQuestion(typeOfSpaceUuid), version); + + // Approximate Area of Shared Space for this relationship — revealed by any of its Type of Space options + SectionQuestion area = svc.ensureQuestion(section, question(areaUuid, + "Approximate Area of Shared Space", "साझा स्थान का अनुमानित क्षेत्रफल", QuestionType.NUMBER_PICKER, + "Give estimated measurement of area", base + 2 + 2 * i)); + svc.ensureValidation(area, validation(ValidationType.REGEX, AREA_REGEX, "Enter a value between 50 and 5000.")); + svc.ensureValidation(area, validation(ValidationType.UNIT, "sq. m.", "Value is in sq. m.")); + + for (int j = 0; j < TYPE_OF_SPACE_OPTIONS.size(); j++) { + String[] o = TYPE_OF_SPACE_OPTIONS.get(j); + QuestionOption spaceOption = svc.ensureOption(typeOfSpace, option(o[0], o[1], o[2], j + 1)); + svc.ensureValidation(area, validation(ValidationType.MANDATORY_IF, + typeOfSpaceUuid + "=" + o[2], MANDATORY_ERROR)); + svc.ensureCondition(spaceOption, showQuestion(areaUuid), version); + } + } + + } + + private SectionQuestionDTO question(String uuid, String text, String textHindi, QuestionType type, + String defaultValue, int order) { + SectionQuestionDTO q = new SectionQuestionDTO(); + q.setQuestionUuid(uuid); + q.setQuestionText(text); + q.setQuestionTextHindi(textHindi); + q.setQuestionType(type); + q.setDefaultValue(defaultValue); + q.setIsMandatory(true); + q.setDisplayOrder(order); + q.setVisibleByDefault(false); + q.setOptions(List.of()); + q.setValidations(List.of()); + return q; + } + + private QuestionOptionDTO option(String label, String labelHindi, String value, int order) { + QuestionOptionDTO o = new QuestionOptionDTO(); + o.setOptionLabel(label); + o.setOptionLabelHindi(labelHindi); + o.setOptionValue(value); + o.setOptionValueHindi(labelHindi); + o.setDisplayOrder(order); + o.setConditions(List.of()); + return o; + } + + private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) { + QuestionValidationDTO v = new QuestionValidationDTO(); + v.setValidationType(type); + v.setValidationParam(param); + v.setErrorMessage(errorMessage); + return v; + } + + private OptionConditionDTO showQuestion(String targetQuestionUuid) { + OptionConditionDTO c = new OptionConditionDTO(); + c.setActionType("SHOW_QUESTION"); + c.setTargetQuestionUuid(targetQuestionUuid); + return c; + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java b/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java new file mode 100644 index 000000000..3486eea07 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java @@ -0,0 +1,129 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.ValidationType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Adds a mandatory "Time spent with contact in hours" number picker to the Community Contact + * Tracing form for each "Relationship with community contacts" (CCT_RELATIONSHIP) option, so the + * exposure duration is captured per relationship type alongside its existing contact count. + * + * One question per option (CCT_RELATIONSHIP_HOURS_<OPTION_VALUE>) rather than a shared one, + * since CCT_RELATIONSHIP is multi-select and a shared field couldn't disambiguate which duration + * belongs to which relationship. Each is hidden by default, revealed by a SHOW_QUESTION condition + * on its option, and mandatory only when that option is selected (MANDATORY_IF). + * + * Each question is inserted directly after its relationship's "Approximate Area of Shared Space" + * (CCT_AREA_OF_SHARED_SPACE_<OPTION_VALUE>), so every relationship's revealed questions read + * Type of Space, Area, Time spent; {@code ensureQuestion} shifts everything after it down by one. + * Must run after V006_PerRelationshipTypeOfSpace, which creates those Area questions — and since it + * runs later, each option's SHOW_QUESTION conditions are also created in that same order. + */ +@Component +public class V007_AddRelationshipHoursQuestions implements FormStructureMigration { + + private static final List RELATIONSHIP_VALUES = List.of( + "NEIGHBOUR", + "FRIEND", + "FELLOW_WORSHIPPER", + "FELLOW_COMMUTER", + "COMMUNITY_GROUP_MEMBER", + "FELLOW_PATIENT", + "OTHER"); + + private static final String QUESTION_TEXT = "Time spent with contact in hours"; + private static final String QUESTION_TEXT_HINDI = "संपर्क के साथ बिताया गया समय (घंटों में)"; + private static final String HOURS_REGEX = "^([0-9]|1[0-9]|2[0-4])$"; + private static final String HOURS_ERROR = "Enter whole hours between 0 and 24"; + + @Override + public String migrationId() { + return "V007"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder")); + FormSection section = svc.section(version, "CCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found")); + SectionQuestion relationship = svc.question(section, "CCT_RELATIONSHIP") + .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found")); + + for (String value : RELATIONSHIP_VALUES) { + String questionUuid = "CCT_RELATIONSHIP_HOURS_" + value; + String areaUuid = "CCT_AREA_OF_SHARED_SPACE_" + value; + + SectionQuestion area = svc.question(section, areaUuid) + .orElseThrow(() -> new IllegalStateException( + "Question " + areaUuid + " not found — must run after V006_PerRelationshipTypeOfSpace")); + SectionQuestion hours = svc.ensureQuestion(section, + hoursQuestion(questionUuid, area.getDisplayOrder() + 1)); + svc.ensureValidation(hours, validation(ValidationType.REGEX, HOURS_REGEX, HOURS_ERROR)); + svc.ensureValidation(hours, validation(ValidationType.MANDATORY_IF, + "CCT_RELATIONSHIP=" + value, "This field is mandatory")); + + QuestionOption option = svc.option(relationship, value) + .orElseThrow(() -> new IllegalStateException("Option CCT_RELATIONSHIP=" + value + " not found")); + OptionConditionDTO show = new OptionConditionDTO(); + show.setActionType("SHOW_QUESTION"); + show.setTargetQuestionUuid(questionUuid); + svc.ensureCondition(option, show, version); + } + } + + private SectionQuestionDTO hoursQuestion(String uuid, int order) { + SectionQuestionDTO q = new SectionQuestionDTO(); + q.setQuestionUuid(uuid); + q.setQuestionText(QUESTION_TEXT); + q.setQuestionTextHindi(QUESTION_TEXT_HINDI); + q.setQuestionType(QuestionType.NUMBER_PICKER); + q.setIsMandatory(true); + q.setDisplayOrder(order); + q.setVisibleByDefault(false); + q.setOptions(List.of()); + q.setValidations(List.of()); + return q; + } + + private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) { + QuestionValidationDTO v = new QuestionValidationDTO(); + v.setValidationType(type); + v.setValidationParam(param); + v.setErrorMessage(errorMessage); + return v; + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java b/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java new file mode 100644 index 000000000..7db08a382 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java @@ -0,0 +1,77 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +/** + * Changes the Community Contact Tracing form's "Relationship with community contacts" question + * (CCT_RELATIONSHIP) from CHECKBOX_MULTI to DROPDOWN_MULTI, so the 7 relationship options render as + * a multi-select dropdown instead of a checkbox list. + * + * Both types submit {@code optionValues} and store one response row per selected option, so + * existing responses remain valid. Options and their conditions (per-relationship count, Type of + * Space and hours questions, and "Other" text) are untouched. All other scalar fields are read off + * the existing row and passed back unchanged, since {@code updateQuestion} overwrites all of them + * unconditionally. + */ +@Component +public class V008_ChangeRelationshipToDropdownMulti implements FormStructureMigration { + + @Override + public String migrationId() { + return "V008"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder")); + FormSection section = svc.section(version, "CCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found")); + SectionQuestion question = svc.question(section, "CCT_RELATIONSHIP") + .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found")); + + if (question.getQuestionType() == QuestionType.DROPDOWN_MULTI) { + return; + } + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionText(question.getQuestionText()); + dto.setQuestionTextHindi(question.getQuestionTextHindi()); + dto.setQuestionType(QuestionType.DROPDOWN_MULTI); + dto.setIsMandatory(question.getIsMandatory()); + dto.setMaxLength(question.getMaxLength()); + dto.setDefaultValue(question.getDefaultValue()); + dto.setContainsPii(question.getContainsPii()); + dto.setDisplayOrder(question.getDisplayOrder()); + + svc.updateQuestion(question.getQuestionId(), dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V009_BackfillOptionUuid.java b/src/main/java/com/iemr/flw/seeder/migration/V009_BackfillOptionUuid.java new file mode 100644 index 000000000..bfcb384d2 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V009_BackfillOptionUuid.java @@ -0,0 +1,62 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.repo.iemr.QuestionOptionRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Backfills t_question_option.optionUuid for every option of every form version created before + * the column existed. The uuid is formType + optionValue, so the same option resolves to the same + * optionUuid in v1, v2, … — letting clients map answers across versions, as with sectionUuid/questionUuid. + * Only touches rows whose optionUuid is still blank, so re-running is a no-op. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class V009_BackfillOptionUuid implements FormStructureMigration { + + private final QuestionOptionRepo optionRepo; + + @Override + public String migrationId() { + return "V009"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + List options = optionRepo.findAllMissingOptionUuid(); + for (QuestionOption option : options) { + option.setOptionUuid(QuestionOption.buildOptionUuid( + option.getSectionQuestion().getFormSection().getFormVersion().getDynamicForm().getFormType(), + option.getOptionValue())); + } + optionRepo.saveAll(options); + log.info("V009: backfilled optionUuid on {} option(s).", options.size()); + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/service/BeneficiaryService.java b/src/main/java/com/iemr/flw/service/BeneficiaryService.java index e69bc887f..d043e7eea 100644 --- a/src/main/java/com/iemr/flw/service/BeneficiaryService.java +++ b/src/main/java/com/iemr/flw/service/BeneficiaryService.java @@ -5,11 +5,14 @@ import com.iemr.flw.dto.iemr.SAMResponseDTO; import java.util.List; +import java.util.Map; public interface BeneficiaryService { String getBenData(GetBenRequestHandler requestDTO, String authorisation) throws Exception; + Map getBeneficiaryRecordsByRegIds(List benRegIds, String authorisation); + String saveEyeCheckupVsit(List eyeCheckupRequestDTOS,String token); List getEyeCheckUpVisit(GetBenRequestHandler request,String token); diff --git a/src/main/java/com/iemr/flw/service/CampConfigService.java b/src/main/java/com/iemr/flw/service/CampConfigService.java index 305bf1f05..325b0dabf 100644 --- a/src/main/java/com/iemr/flw/service/CampConfigService.java +++ b/src/main/java/com/iemr/flw/service/CampConfigService.java @@ -1,8 +1,14 @@ package com.iemr.flw.service; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.List; + /** * Camp/van identity for this deployment. * @@ -17,12 +23,14 @@ * No inline default — every properties file must set stoptb.van.id explicitly, so a forgotten * config fails loudly at startup instead of silently running unconfigured. * - * Scope: vanID (and the VanSerialNo stamping that depends on it) only. parkingPlaceID is out of - * scope for this change. + * The van's parking place and store (facility) come from m_van, the same source MMU uses + * (MasterVanRepo.getFacilityID), and are read once and cached since the van never changes. */ @Service public class CampConfigService { + private final Logger logger = LoggerFactory.getLogger(CampConfigService.class); + @Value("${stoptb.van.id}") private int vanID; @@ -31,6 +39,12 @@ public class CampConfigService { @Value("${stoptb.enforce.vanid}") private boolean enforceVanID; + @PersistenceContext(unitName = "db_iemr") + private EntityManager entityManager; + + private volatile Integer parkingPlaceID; + private volatile Integer facilityID; + public Integer getVanID() { if (vanID <= 0) { if (enforceVanID) { @@ -42,13 +56,51 @@ public Integer getVanID() { return vanID; } + /** + * The configured van (stoptb.van.id) is always used. A vanID sent by the app is ignored, so data and + * stock never land on another camp's van/store; a different value is only logged. + */ + public Integer resolveVanID(Integer requestedVanID) { + Integer configured = getVanID(); + if (requestedVanID != null && requestedVanID > 0 && !requestedVanID.equals(configured)) { + logger.warn("Ignoring vanID " + requestedVanID + " from request; using stoptb.van.id " + configured); + } + return configured; + } + public boolean isCampConfigured() { return vanID > 0; } - // Not sourced from Redis anymore (out of scope for this change) — kept only because - // callers across StopTBServiceImpl/DiagnosticOrderServiceImpl/etc. still call it. + // Parking place of the configured van (m_van.ParkingPlaceID); 0 when the camp is not configured + // or the van is not found, which is what callers received before this lookup existed. public Integer getParkingPlaceID() { - return 0; + if (parkingPlaceID == null) loadVanDetails(); + return parkingPlaceID != null ? parkingPlaceID : 0; + } + + // Store mapped to the configured van (m_van.FacilityID); null when no store is mapped. + public Integer getFacilityID() { + if (facilityID == null) loadVanDetails(); + return facilityID; + } + + private synchronized void loadVanDetails() { + if (!isCampConfigured() || (parkingPlaceID != null && facilityID != null)) return; + try { + List rows = entityManager.createNativeQuery( + "SELECT ParkingPlaceID, FacilityID FROM db_iemr.m_van WHERE VanID = :vanID AND Deleted = false") + .setParameter("vanID", vanID) + .getResultList(); + if (rows.isEmpty()) { + logger.warn("stoptb.van.id " + vanID + " not found in m_van"); + return; + } + Object[] row = (Object[]) rows.get(0); + parkingPlaceID = row[0] != null ? ((Number) row[0]).intValue() : null; + facilityID = row[1] != null ? ((Number) row[1]).intValue() : null; + } catch (Exception e) { + logger.error("Cannot read m_van details for vanID " + vanID, e); + } } } diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java index 0bd5207b8..42fb1c337 100644 --- a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -12,18 +12,50 @@ public interface DiagnosticOrderService { - DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception; + DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception; + + /** + * Same create/push logic as createAndPushOrder, attributed to "SYSTEM" instead of a JWT-derived + * user. For callers with no authenticated user in context, e.g. the poll scheduler auto-recreating + * an order after an invalid result. + */ + DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto request) throws Exception; DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult result) throws Exception; + /** + * Polls the vendor once for this order. The order is re-read first and skipped (returns null) if it + * is no longer PENDING/IN_PROGRESS, e.g. a manual result landed after the scheduler loaded it. + */ DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception; + /** + * Daily cutoff: marks this order CLOSED with the given reason and notifies the vendor, unless it was + * resolved (e.g. by a manual result) after the scheduler loaded it. + */ + void expireOrder(DiagnosticOrder order, String reason) throws Exception; + DiagnosticOrderResultDto triggerManualPoll(Long beneficiaryId, String orderType, Long visitCode) throws Exception; DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visitCode) throws Exception; + /** + * Best-effort: tells the vendor this order is closed out (no result coming), so they stop + * tracking it. Never throws — a provider failure is logged and swallowed, since the order's own + * CLOSED status is what actually matters locally. No-op if the order was never pushed to a vendor. + */ + void notifyProviderOrderClosed(DiagnosticOrder order, String reason); + DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderType, Long visitCode); + /** + * Orders with their result, keyed by order id (STOP-TB changes feed). Deleted orders are left + * out. Each record carries what getBeneficiariesByStatus and getOrderResult give today: the + * beneficiary, order type, visit code and status (the app buckets by the latest order per + * beneficiary and type), plus the result fields. + */ + java.util.Map> getOrderRecordsByIds(java.util.Collection ids); + DiagnosticOrder getOrder(Long beneficiaryId, String orderType, Long visitCode) throws Exception; List getOrdersByBeneficiaryId(Long beneficiaryId) throws Exception; @@ -32,5 +64,5 @@ public interface DiagnosticOrderService { VendorHealthDto checkVendorHealth(String orderType) throws Exception; - DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request) throws Exception; + DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request, String jwtToken) throws Exception; } diff --git a/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java b/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java index c7aae24df..b3e5558c9 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java @@ -23,6 +23,7 @@ import com.iemr.flw.dto.iemr.DynamicFormDTO; import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.LatestFormVersionDTO; import com.iemr.flw.dto.iemr.OptionConditionDTO; import com.iemr.flw.dto.iemr.QuestionOptionDTO; import com.iemr.flw.dto.iemr.QuestionValidationDTO; @@ -47,7 +48,7 @@ public interface DynamicFormDefinitionService { /** Returns a specific version's full definition tree (no cache). */ DynamicFormDTO getFormDefinitionByVersion(Long formId, Integer versionNumber); - /** Returns metadata for all active forms (without full tree). */ + /** Returns every active form's latest full definition, with each version's full definition in {@code versions}. */ List getAllForms(); /** Sets the form's isActive flag to true. */ @@ -56,4 +57,6 @@ public interface DynamicFormDefinitionService { /** Sets the form's isActive flag to false and invalidates cache. */ void deactivateForm(Long formId); + /** Latest version of every active form: formId, formUuid, formName and current version number. */ + List getLatestFormVersions(); } diff --git a/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java b/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java new file mode 100644 index 000000000..f558f0078 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java @@ -0,0 +1,101 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.service; + +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.OptionCondition; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; + +import java.util.Optional; + +/** + * Idempotent, in-place reconciliation for dynamic form structure — inserts whatever is missing + * from the current latest {@link FormVersion} and leaves existing rows untouched. Never creates a + * new version (unlike {@link DynamicFormDefinitionService#updateForm}, which always clones one). + * + * Used both by the startup seeders (walking their full hardcoded DTO tree every run) and by the + * granular structure endpoints (a single section/question/option at a time). + * + * Each {@code ensureX} method operates on exactly one level — callers walk the tree themselves and + * call the next level down, since only the caller knows whether it's safe yet to wire cross-sibling + * references (see {@link #ensureCondition}). + */ +public interface DynamicFormReconciliationService { + + /** Finds the form by formUuid, or creates it. If found inactive, reactivates it (touches no other field). */ + DynamicForm ensureForm(DynamicFormDTO dto); + + /** Finds the form's isLatest=true version, or creates version 1 if none exists yet. */ + FormVersion ensureLatestVersion(DynamicForm form); + + /** Finds a section by sectionUuid within the version, or creates it. */ + FormSection ensureSection(FormVersion version, FormSectionDTO dto); + + /** Finds a question by questionUuid within the section, or creates it (a section's question is "linked" the moment it's created — no separate link step exists). */ + SectionQuestion ensureQuestion(FormSection section, SectionQuestionDTO dto); + + /** Finds an option by optionValue within the question, or creates it. */ + QuestionOption ensureOption(SectionQuestion question, QuestionOptionDTO dto); + + /** Finds a validation by (validationType, validationParam) within the question, or creates it — validations carry no natural key of their own. */ + QuestionValidation ensureValidation(SectionQuestion question, QuestionValidationDTO dto); + + /** + * Finds a condition by (actionType, target) within the option, or creates it, resolving + * targetQuestionUuid/targetSectionUuid against the given version. Call only once every sibling + * section/question this condition might target already exists as a row. + */ + OptionCondition ensureCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version); + + /** Updates a question's scalar fields only (options/validations are managed by their own endpoints). A displayOrder change moves the question among its siblings, shifting them accordingly. */ + SectionQuestion updateQuestion(Long questionId, SectionQuestionDTO dto); + + /** Soft-deletes (isActive=false) a question — never hard-deleted; historical responses may reference it. */ + void unlinkQuestion(Long questionId); + + /** Soft-deletes (isActive=false) an option — never hard-deleted; historical responses may reference it. */ + void removeOption(Long optionId); + + // ── Read-only lookups, for FormStructureMigration classes to locate what they're changing ── + + /** Finds the given form's current latest version, if the form exists at all. */ + Optional latestVersion(String formUuid); + + /** Finds a section by sectionUuid within the given version. */ + Optional section(FormVersion version, String sectionUuid); + + /** Finds a question by questionUuid within the given section. */ + Optional question(FormSection section, String questionUuid); + + /** Finds an option by optionValue within the given question. */ + Optional option(SectionQuestion question, String optionValue); +} diff --git a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java index 1f2255f3e..8265cfafb 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java @@ -55,6 +55,12 @@ List getResponsesByBeneficiary(Long beneficiaryId, Integer vill /** Single response with all nested section and question answers. */ FormResponseDTO getResponseById(Long responseId); + /** + * Responses with all nested answers, keyed by responseId, as getResponsesByBeneficiary returns + * them (STOP-TB changes feed). Ids with no response are left out. + */ + java.util.Map getResponsesByIds(java.util.Collection responseIds); + /** * Submit multiple form responses in one bulk transaction. * All FormResponse rows are batch-inserted via saveAll() so responseIds are available diff --git a/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java new file mode 100644 index 000000000..c61b4d4f9 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/StopTBPrescriptionService.java @@ -0,0 +1,721 @@ +package com.iemr.flw.service; + +import com.iemr.flw.domain.identity.RMNCHMBeneficiarydetail; +import com.iemr.flw.domain.identity.RMNCHMBeneficiarymapping; +import com.iemr.flw.domain.iemr.BenVisitDetail; +import com.iemr.flw.repo.identity.BeneficiaryRepo; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +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.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.Period; +import java.util.*; + +/** + * Stop TB nurse OPD: chief complaints, prescription and dispensing written to the same standard + * tables MMU/TM and Inventory-API use, inside the caller's transaction. + * + * - Chief complaints -> t_benchiefcomplaint (one row per complaint, like TM saveBenChiefComplaints) + * - Prescription -> t_prescription + t_prescribeddrug (DrugID + QtyPrescribed, like TM doctor save) + * - Dispensing -> t_patientissue + t_itemstockexit (ExitType T_PatientIssue) and + * t_itemstockentry.QuantityInHand reduced, like Inventory-API issuePatientDrugs. + * + * Batches are picked by m_itemcategory.IssueType (FEFO / FIFO / LIFO) from non-expired batches that + * last the treatment duration, and are locked (SELECT ... FOR UPDATE) so two nurses can never take the + * same stock. If any drug is short, nothing is saved (InsufficientStockException rolls back the caller). + * + * Native SQL only, no new entity mappings: FLW runs with ddl-auto=create. + */ +@Service +public class StopTBPrescriptionService { + + private static final String REFERENCE_PREFIX = "StopTB:"; + + @PersistenceContext(unitName = "db_iemr") + private EntityManager em; + + @Autowired + private CampConfigService campConfigService; + + @Autowired + private BeneficiaryRepo beneficiaryRepo; + + // Off by default: save the prescription only (no store/stock check, no patient issue, stock unchanged). + // true = check stock and dispense from the camp store. + @Value("${stoptb.dispense.enabled:false}") + private boolean dispenseEnabled; + + private final Logger logger = LoggerFactory.getLogger(StopTBPrescriptionService.class); + + public static class InsufficientStockException extends RuntimeException { + public InsufficientStockException(String message) { + super(message); + } + } + + /** Previous result for a submissionId already saved, or null. Lets the app retry safely. */ + @Transactional(propagation = Propagation.MANDATORY) + public Map findPreviousSubmission(Long beneficiaryRegID, String submissionId) { + if (submissionId == null || submissionId.isBlank()) return null; + List rows = em.createNativeQuery("SELECT PatientIssueID, VisitCode, PrescriptionID FROM db_iemr.t_patientissue " + + "WHERE BeneficiaryRegID = :ben AND Reference = :ref AND Deleted = false") + .setParameter("ben", beneficiaryRegID) + .setParameter("ref", REFERENCE_PREFIX + submissionId) + .getResultList(); + if (rows.isEmpty()) return null; + Object[] row = (Object[]) rows.get(0); + Integer facilityID = campConfigService.getFacilityID(); + + List issued = em.createNativeQuery("SELECT e.ItemID, i.ItemName, SUM(x.Quantity) " + + "FROM db_iemr.t_itemstockexit x " + + "JOIN db_iemr.t_itemstockentry e ON e.VanSerialNo = x.ItemStockEntryID AND e.FacilityID = x.FacilityID " + + "JOIN db_iemr.m_item i ON i.ItemID = e.ItemID " + + "WHERE x.ExitType = 'T_PatientIssue' AND x.ExitTypeID = :issueID AND x.FacilityID = :facilityID " + + "GROUP BY e.ItemID, i.ItemName") + .setParameter("issueID", ((Number) row[0]).longValue()) + .setParameter("facilityID", facilityID) + .getResultList(); + List> dispensed = new ArrayList<>(); + for (Object o : issued) { + Object[] r = (Object[]) o; + int drugID = ((Number) r[0]).intValue(); + Map d = new LinkedHashMap<>(); + d.put("drugID", drugID); + d.put("drugName", r[1]); + d.put("qtyIssued", ((Number) r[2]).intValue()); + d.put("availableQuantity", availableQuantity(facilityID, drugID, 0)); + dispensed.add(d); + } + Map result = new LinkedHashMap<>(); + result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", row[1] != null ? ((Number) row[1]).longValue() : null); + result.put("prescriptionID", row[2] != null ? ((Number) row[2]).longValue() : null); + result.put("dispensed", dispensed); + result.put("duplicate", true); + return result; + } + + /** + * Replaces the visit's chief complaints with the given list (edit = remove old + insert new, as MMU + * does on update). Rows without an ID and without a name are skipped. + */ + @Transactional(propagation = Propagation.MANDATORY) + public void saveChiefComplaints(List> complaints, Long beneficiaryRegID, BenVisitDetail visit, + String createdBy, Integer vanID, Integer parkingPlaceID) { + if (complaints == null) return; + // Processed='N' so the removal is uploaded to central again (central updates rows it already has). + em.createNativeQuery("UPDATE db_iemr.t_benchiefcomplaint SET Deleted = true, Processed = 'N', ModifiedBy = :user " + + "WHERE BeneficiaryRegID = :ben AND VisitCode = :visitCode AND Deleted = false") + .setParameter("user", createdBy) + .setParameter("ben", beneficiaryRegID) + .setParameter("visitCode", visit.getVisitCode()) + .executeUpdate(); + for (Map c : complaints) { + Integer complaintID = toInt(c.get("chiefComplaintID")); + String complaint = toStr(c.get("chiefComplaint")); + // ID from the chief complaint master is required: getAll and reports identify new records by it. + if (complaintID == null) { + throw new IllegalArgumentException("chiefComplaintID is required for every chief complaint" + + (complaint != null ? " (" + complaint + ")" : "")); + } + em.createNativeQuery("INSERT INTO db_iemr.t_benchiefcomplaint (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, " + + "VisitCode, ChiefComplaintID, ChiefComplaint, Duration, UnitOfDuration, Description, Deleted, " + + "Processed, CreatedBy, VanID, ParkingPlaceID) VALUES (:ben, :visitID, :psm, :visitCode, :cid, " + + ":name, :duration, :unit, :description, false, 'N', :user, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("psm", visit.getProviderServiceMapID()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("cid", complaintID) + .setParameter("name", complaint) + .setParameter("duration", toInt(c.get("duration"))) + .setParameter("unit", toStr(c.get("unitOfDuration"))) + .setParameter("description", toStr(c.get("description"))) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + stampVanSerialNo("t_benchiefcomplaint", "ID", lastInsertId()); + } + } + + /** + * Saves the prescription and issues every drug from the camp store. Returns prescriptionID and, + * per drug, the quantity issued and what is left in the store. + */ + @Transactional(propagation = Propagation.MANDATORY) + public Map prescribeAndDispense(Map prescription, Long beneficiaryRegID, + BenVisitDetail visit, String submissionId, String createdBy, Integer vanID, Integer parkingPlaceID) { + List> drugs = toList(prescription.get("drugs")); + if (!dispenseEnabled) { + return prescribeWithoutDispensing(prescription, drugs, beneficiaryRegID, visit, createdBy, vanID, + parkingPlaceID); + } + Map result = new LinkedHashMap<>(); + if (drugs.isEmpty()) { + // Remarks without medicine are still kept, as MMU saves the visit instruction without drugs. + String instruction = toStr(prescription.get("instruction")); + if (instruction != null && !instruction.isBlank()) { + result.put("prescriptionID", insertPrescription(beneficiaryRegID, visit, instruction, createdBy, vanID, + parkingPlaceID)); + } + return result; + } + + Integer facilityID = campConfigService.getFacilityID(); + if (facilityID == null) { + throw new IllegalStateException("No store is mapped to van " + vanID + " (m_van.FacilityID)"); + } + + // 1. Validate every line and check total stock per drug before writing anything. + Map requested = new LinkedHashMap<>(); + Map items = new HashMap<>(); + for (Map d : drugs) { + Integer drugID = toInt(d.get("drugID")); + if (drugID == null) throw new IllegalArgumentException("drugID is required for every drug"); + if (!items.containsKey(drugID)) items.put(drugID, getStoreItem(facilityID, drugID)); + Integer qty = toInt(d.get("qtyPrescribed")); + if (qty == null || qty <= 0) { + // No quantity from the app: tablets/capsules = doses per day x days, other forms = 1. + qty = calculateQuantity(d, toInt(items.get(drugID)[7])); + if (qty == null) { + throw new IllegalArgumentException("qtyPrescribed is required for drugID " + drugID + + " (tablet/capsule quantity needs frequency and duration)"); + } + d.put("qtyPrescribed", qty); // saved in t_prescribeddrug.QtyPrescribed + } + requested.merge(drugID, qty, Integer::sum); + } + List shortages = new ArrayList<>(); + Map> lockedBatches = new HashMap<>(); + for (Map.Entry r : requested.entrySet()) { + Object[] item = items.get(r.getKey()); + int durationDays = maxDurationDays(drugs, r.getKey()); + List batches = lockBatches(facilityID, r.getKey(), (String) item[6], durationDays); + lockedBatches.put(r.getKey(), batches); + int available = batches.stream().mapToInt(b -> ((Number) b[1]).intValue()).sum(); + if (available < r.getValue()) { + shortages.add(item[1] + " (drugID " + r.getKey() + "): asked " + r.getValue() + ", available " + available); + } + } + if (!shortages.isEmpty()) { + throw new InsufficientStockException("Insufficient stock - " + String.join("; ", shortages)); + } + + // 2. Prescription + prescribed drugs (Stop TB service line, same as the visit). + Integer storePsm = getStorePsm(facilityID); + long prescriptionID = insertPrescription(beneficiaryRegID, visit, toStr(prescription.get("instruction")), + createdBy, vanID, parkingPlaceID); + + for (Map d : drugs) { + Integer drugID = toInt(d.get("drugID")); + Object[] item = items.get(drugID); + em.createNativeQuery("INSERT INTO db_iemr.t_prescribeddrug (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, " + + "VisitCode, PrescriptionID, DrugForm, DrugID, GenericDrugName, DrugStrength, Dose, Route, Frequency, " + + "Duration, DuartionUnit, SpecialInstruction, QtyPrescribed, isEDL, Deleted, Processed, CreatedBy, " + + "VanID, ParkingPlaceID) VALUES (:ben, :visitID, :psm, :visitCode, :prescriptionID, :form, :drugID, " + + ":name, :strength, :dose, :route, :frequency, :duration, :durationUnit, :instruction, :qty, " + + ":isEDL, false, 'N', :user, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("psm", visit.getProviderServiceMapID()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("prescriptionID", prescriptionID) + .setParameter("form", item[3]) + .setParameter("drugID", drugID) + .setParameter("name", item[1]) + .setParameter("strength", item[2]) + .setParameter("dose", toStr(d.get("dose"))) + .setParameter("route", item[4]) + .setParameter("frequency", toStr(d.get("frequency"))) + .setParameter("duration", toStr(d.get("duration"))) + .setParameter("durationUnit", toStr(d.get("durationUnit"))) + .setParameter("instruction", toStr(d.get("instructions"))) + .setParameter("qty", toInt(d.get("qtyPrescribed"))) + .setParameter("isEDL", item[5]) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + stampVanSerialNo("t_prescribeddrug", "PrescribedDrugID", lastInsertId()); + } + + // 3. Patient issue header (same shape as Inventory-API issuePatientDrugs, IssueType 'System'). + Object[] patient = getPatientDetails(beneficiaryRegID); + em.createNativeQuery("INSERT INTO db_iemr.t_patientissue (BeneficiaryRegID, BenVisitID, VisitCode, FacilityID, " + + "PatientName, Age, Gender, PrescriptionID, Reference, IssueType, IssuedBy, ProviderServiceMapID, " + + "Deleted, Processed, CreatedBy, SyncFacilityID, VanID, ParkingPlaceID) VALUES (:ben, :visitID, " + + ":visitCode, :facilityID, :name, :age, :gender, :prescriptionID, :ref, 'System', :user, :psm, false, " + + "'N', :user, :facilityID, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("name", patient[0]) + .setParameter("age", patient[1]) + .setParameter("gender", patient[2]) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("facilityID", facilityID) + .setParameter("prescriptionID", prescriptionID) + .setParameter("ref", submissionId != null && !submissionId.isBlank() ? REFERENCE_PREFIX + submissionId : null) + .setParameter("user", createdBy) + .setParameter("psm", storePsm) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + long patientIssueID = lastInsertId(); + stampVanSerialNo("t_patientissue", "PatientIssueID", patientIssueID); + + // 4. Take each drug from its locked batches in issue order; one stock exit per batch used. + List> dispensed = new ArrayList<>(); + for (Map.Entry r : requested.entrySet()) { + int remaining = r.getValue(); + for (Object[] batch : lockedBatches.get(r.getKey())) { + if (remaining == 0) break; + long batchSerialNo = ((Number) batch[0]).longValue(); + int take = Math.min(remaining, ((Number) batch[1]).intValue()); + em.createNativeQuery("INSERT INTO db_iemr.t_itemstockexit (ItemStockEntryID, FacilityID, Quantity, " + + "ProviderServiceMapID, ExitTypeID, ExitType, Deleted, Processed, CreatedBy, SyncFacilityID, " + + "VanID, ParkingPlaceID) VALUES (:batch, :facilityID, :qty, :psm, :issueID, 'T_PatientIssue', " + + "false, 'N', :user, :facilityID, :van, :pp)") + .setParameter("batch", batchSerialNo) + .setParameter("facilityID", facilityID) + .setParameter("qty", take) + .setParameter("psm", storePsm) + .setParameter("issueID", patientIssueID) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + stampVanSerialNo("t_itemstockexit", "ItemStockExitID", lastInsertId()); + // Same update as Inventory-API ItemStockEntryRepo.updateStock. Processed='N' sends the batch's + // new stock to central on the next upload; central updates its copy by VanSerialNo + store. + em.createNativeQuery("UPDATE db_iemr.t_itemstockentry SET QuantityInHand = QuantityInHand - :qty, " + + "Processed = 'N' WHERE VanSerialNo = :batch AND FacilityID = :facilityID") + .setParameter("qty", take) + .setParameter("batch", batchSerialNo) + .setParameter("facilityID", facilityID) + .executeUpdate(); + remaining -= take; + } + Map d = new LinkedHashMap<>(); + d.put("drugID", r.getKey()); + d.put("drugName", items.get(r.getKey())[1]); + d.put("qtyIssued", r.getValue()); + d.put("availableQuantity", availableQuantity(facilityID, r.getKey(), 0)); + dispensed.add(d); + } + + result.put("prescriptionID", prescriptionID); + result.put("dispensed", dispensed); + return result; + } + + /** + * OPD records for getAll from the standard tables, one per visit that was saved with the new payload + * (complaint with ChiefComplaintID or drug with DrugID). Older visits have only the free-text copy in + * tb_stoptb_general_opd and are added by the caller. Field names and formats match that table's response: + * chiefComplaint = JSON list of names, medication/dosage/frequency/duration = comma separated. + */ + @SuppressWarnings("unchecked") + public List> getStandardOpdRecords(Integer providerServiceMapID, Integer villageID) { + String villageFilter = villageID != null + ? "AND v.BeneficiaryRegID IN (SELECT f.beneficiary_reg_id FROM db_iemr.i_ben_flow_outreach f " + + "WHERE f.providerServiceMapID = :psm AND f.villageID = :villageID) " + : ""; + Map params = new HashMap<>(); + params.put("psm", providerServiceMapID); + if (villageID != null) params.put("villageID", villageID); + return standardOpdRecords("v.ProviderServiceMapID = :psm " + villageFilter, params); + } + + // The same records for specific visits (BenVisitID), for the STOP-TB changes feed. A visit that is + // deleted or has no complaint or drug is left out, exactly as getStandardOpdRecords leaves it out. + public List> getStandardOpdRecordsByVisitIds(Collection benVisitIds) { + if (benVisitIds.isEmpty()) return new ArrayList<>(); + Map params = new HashMap<>(); + params.put("ids", benVisitIds); + return standardOpdRecords("v.BenVisitID IN (:ids) ", params); + } + + // Visit codes among these that getStandardOpdRecords would return (not deleted, with a complaint or drug). + public Set visitCodesWithStandardOpd(Collection visitCodes) { + Set out = new HashSet<>(); + if (visitCodes.isEmpty()) return out; + Map params = new HashMap<>(); + params.put("codes", visitCodes); + for (Map m : standardOpdRecords("v.VisitCode IN (:codes) ", params)) { + out.add((Long) m.get("visitCode")); + } + return out; + } + + private List> standardOpdRecords(String visitFilter, Map params) { + Query visitQuery = em.createNativeQuery("SELECT v.BenVisitID, v.BeneficiaryRegID, v.VisitCode, v.CreatedBy, " + + "v.CreatedDate, v.LastModDate, v.ProviderServiceMapID FROM db_iemr.t_benvisitdetail v " + + "WHERE " + visitFilter + "AND v.Deleted = false " + + "AND (EXISTS (SELECT 1 FROM db_iemr.t_benchiefcomplaint c WHERE c.BeneficiaryRegID = v.BeneficiaryRegID " + + " AND c.VisitCode = v.VisitCode AND c.ChiefComplaintID IS NOT NULL AND c.Deleted = false) " + + " OR EXISTS (SELECT 1 FROM db_iemr.t_prescribeddrug d WHERE d.BeneficiaryRegID = v.BeneficiaryRegID " + + " AND d.VisitCode = v.VisitCode AND d.DrugID IS NOT NULL AND d.Deleted = false)) " + + "ORDER BY v.CreatedDate DESC"); + params.forEach(visitQuery::setParameter); + List visits = visitQuery.getResultList(); + + Map> byVisit = new LinkedHashMap<>(); + for (Object[] v : visits) { + Map m = new LinkedHashMap<>(); + m.put("id", ((Number) v[0]).longValue()); + m.put("beneficiaryRegID", ((Number) v[1]).longValue()); + m.put("visitCode", ((Number) v[2]).longValue()); + m.put("providerServiceMapID", v[6] != null ? ((Number) v[6]).intValue() : null); + m.put("chiefComplaint", new ArrayList()); + m.put("medication", new StringJoiner(", ")); + m.put("dosage", new StringJoiner(", ")); + m.put("frequency", new StringJoiner(", ")); + m.put("duration", new StringJoiner(", ")); + m.put("drugs", new ArrayList>()); + m.put("notes", null); + m.put("createdBy", v[3]); + m.put("createdDate", v[4]); + m.put("updateDate", v[5]); + m.put("updatedBy", null); + byVisit.put(((Number) v[2]).longValue(), m); + } + if (byVisit.isEmpty()) return new ArrayList<>(); + + List visitCodes = new ArrayList<>(byVisit.keySet()); + for (int i = 0; i < visitCodes.size(); i += 500) { + List chunk = visitCodes.subList(i, Math.min(i + 500, visitCodes.size())); + + for (Object o : em.createNativeQuery("SELECT VisitCode, ChiefComplaint, LastModDate FROM db_iemr.t_benchiefcomplaint " + + "WHERE VisitCode IN (:codes) AND ChiefComplaintID IS NOT NULL AND Deleted = false ORDER BY ID") + .setParameter("codes", chunk).getResultList()) { + Object[] r = (Object[]) o; + Map m = byVisit.get(((Number) r[0]).longValue()); + if (r[1] != null) ((List) m.get("chiefComplaint")).add(r[1].toString()); + latest(m, r[2]); + } + + for (Object o : em.createNativeQuery("SELECT d.VisitCode, d.GenericDrugName, d.Dose, d.Frequency, d.Duration, " + + "d.DuartionUnit, d.LastModDate, d.DrugID, i.ItemFormID, d.DrugForm, d.QtyPrescribed, " + + "d.SpecialInstruction FROM db_iemr.t_prescribeddrug d " + + "LEFT JOIN db_iemr.m_item i ON i.ItemID = d.DrugID " + + "WHERE d.VisitCode IN (:codes) AND d.DrugID IS NOT NULL AND d.Deleted = false " + + "ORDER BY d.PrescribedDrugID") + .setParameter("codes", chunk).getResultList()) { + Object[] r = (Object[]) o; + Map m = byVisit.get(((Number) r[0]).longValue()); + ((StringJoiner) m.get("medication")).add(r[1] != null ? r[1].toString() : ""); + ((StringJoiner) m.get("dosage")).add(r[2] != null ? r[2].toString() : ""); + ((StringJoiner) m.get("frequency")).add(r[3] != null ? r[3].toString() : ""); + ((StringJoiner) m.get("duration")).add(r[4] == null ? "" : r[5] == null ? r[4].toString() : r[4] + " " + r[5]); + latest(m, r[6]); + + // Full prescription per drug, so a saved record can show form, quantity and instructions. + Map drug = new LinkedHashMap<>(); + drug.put("drugID", ((Number) r[7]).intValue()); + drug.put("drugName", r[1]); + drug.put("itemFormID", r[8] != null ? ((Number) r[8]).intValue() : null); + drug.put("drugForm", r[9]); + drug.put("dose", r[2]); + drug.put("frequency", r[3]); + drug.put("duration", r[4] != null ? toInt(r[4]) : null); + drug.put("durationUnit", r[5]); + drug.put("qtyPrescribed", r[10] != null ? ((Number) r[10]).intValue() : null); + drug.put("instructions", r[11]); + ((List>) m.get("drugs")).add(drug); + } + + // Latest prescription instruction per visit is the visit's note. + for (Object o : em.createNativeQuery("SELECT VisitCode, Instruction, LastModDate FROM db_iemr.t_prescription " + + "WHERE VisitCode IN (:codes) AND Deleted = false ORDER BY PrescriptionID") + .setParameter("codes", chunk).getResultList()) { + Object[] r = (Object[]) o; + Map m = byVisit.get(((Number) r[0]).longValue()); + if (r[1] != null && !r[1].toString().isBlank()) m.put("notes", r[1].toString()); + latest(m, r[2]); + } + } + + com.google.gson.Gson gson = new com.google.gson.Gson(); + List> records = new ArrayList<>(); + for (Map m : byVisit.values()) { + List complaints = (List) m.get("chiefComplaint"); + m.put("chiefComplaint", complaints.isEmpty() ? null : gson.toJson(complaints)); + for (String key : new String[] { "medication", "dosage", "frequency", "duration" }) { + String joined = m.get(key).toString(); + m.put(key, joined.replace(",", "").isBlank() ? null : joined); + } + records.add(m); + } + return records; + } + + // updateDate = latest change on the visit, so the app picks up a later save on the same day. + private void latest(Map m, Object modDate) { + if (modDate instanceof Date && (m.get("updateDate") == null || ((Date) modDate).after((Date) m.get("updateDate")))) { + m.put("updateDate", modDate); + } + } + + // Name, age (years), gender for the issue row shown on the Inventory dispense screen. Missing details + // never block dispensing, same as Inventory-API. + private Object[] getPatientDetails(Long beneficiaryRegID) { + try { + RMNCHMBeneficiarymapping mapping = beneficiaryRepo.getById(BigInteger.valueOf(beneficiaryRegID)); + RMNCHMBeneficiarydetail detail = (mapping != null && mapping.getBenDetailsId() != null) + ? beneficiaryRepo.getDetailsById(mapping.getBenDetailsId()) + : null; + if (detail == null) return new Object[3]; + StringJoiner name = new StringJoiner(" "); + for (String part : new String[] { detail.getFirstName(), detail.getMiddleName(), detail.getLastName() }) { + if (part != null && !part.isBlank()) name.add(part.trim()); + } + Integer age = detail.getDob() != null + ? Period.between(detail.getDob().toLocalDateTime().toLocalDate(), LocalDate.now()).getYears() + : null; + String fullName = name.length() > 150 ? name.toString().substring(0, 150) : name.toString(); + return new Object[] { fullName.isEmpty() ? null : fullName, age, detail.getGender() }; + } catch (Exception e) { + logger.warn("Cannot read patient details for benRegID " + beneficiaryRegID + ": " + e.getMessage()); + return new Object[3]; + } + } + + /** + * Dispensing off (stoptb.dispense.enabled=false): the prescription is saved as the app sent it. No store or + * stock check and no patient issue, so drugs from the app's own list need not exist in this camp's store; + * DrugID, name and form are taken from the request. A new save replaces the visit's earlier prescription + * (same as chief complaints), so an edit or retry does not leave a second copy. + */ + private Map prescribeWithoutDispensing(Map prescription, + List> drugs, Long beneficiaryRegID, BenVisitDetail visit, String createdBy, + Integer vanID, Integer parkingPlaceID) { + for (Map d : drugs) { + if (toInt(d.get("drugID")) == null) throw new IllegalArgumentException("drugID is required for every drug"); + } + // Processed='N' so the removal is uploaded to central again (central updates rows it already has). + for (String table : new String[] { "t_prescribeddrug", "t_prescription" }) { + em.createNativeQuery("UPDATE db_iemr." + table + " SET Deleted = true, Processed = 'N', ModifiedBy = :user " + + "WHERE BeneficiaryRegID = :ben AND VisitCode = :visitCode AND Deleted = false") + .setParameter("user", createdBy) + .setParameter("ben", beneficiaryRegID) + .setParameter("visitCode", visit.getVisitCode()) + .executeUpdate(); + } + + Map result = new LinkedHashMap<>(); + String instruction = toStr(prescription.get("instruction")); + if (!drugs.isEmpty() || (instruction != null && !instruction.isBlank())) { + long prescriptionID = insertPrescription(beneficiaryRegID, visit, instruction, createdBy, vanID, + parkingPlaceID); + for (Map d : drugs) { + Integer qty = toInt(d.get("qtyPrescribed")); + if (qty == null || qty <= 0) qty = calculateQuantity(d, toInt(d.get("itemFormID"))); // null if unknown + em.createNativeQuery("INSERT INTO db_iemr.t_prescribeddrug (BeneficiaryRegID, BenVisitID, " + + "ProviderServiceMapID, VisitCode, PrescriptionID, DrugForm, DrugID, GenericDrugName, Dose, " + + "Frequency, Duration, DuartionUnit, SpecialInstruction, QtyPrescribed, Deleted, Processed, " + + "CreatedBy, VanID, ParkingPlaceID) VALUES (:ben, :visitID, :psm, :visitCode, " + + ":prescriptionID, :form, :drugID, :name, :dose, :frequency, :duration, :durationUnit, " + + ":instruction, :qty, false, 'N', :user, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("psm", visit.getProviderServiceMapID()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("prescriptionID", prescriptionID) + .setParameter("form", toStr(d.get("drugForm"))) + .setParameter("drugID", toInt(d.get("drugID"))) + .setParameter("name", toStr(d.get("drugName"))) + .setParameter("dose", toStr(d.get("dose"))) + .setParameter("frequency", toStr(d.get("frequency"))) + .setParameter("duration", toStr(d.get("duration"))) + .setParameter("durationUnit", toStr(d.get("durationUnit"))) + .setParameter("instruction", toStr(d.get("instructions"))) + .setParameter("qty", qty) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + stampVanSerialNo("t_prescribeddrug", "PrescribedDrugID", lastInsertId()); + } + result.put("prescriptionID", prescriptionID); + } + result.put("dispensed", new ArrayList>()); + return result; + } + + // t_prescription row for the visit (Stop TB service line, same as the visit). + private long insertPrescription(Long beneficiaryRegID, BenVisitDetail visit, String instruction, String createdBy, + Integer vanID, Integer parkingPlaceID) { + em.createNativeQuery("INSERT INTO db_iemr.t_prescription (BeneficiaryRegID, BenVisitID, ProviderServiceMapID, VisitCode, " + + "Instruction, Deleted, Processed, CreatedBy, VanID, ParkingPlaceID) " + + "VALUES (:ben, :visitID, :psm, :visitCode, :instruction, false, 'N', :user, :van, :pp)") + .setParameter("ben", beneficiaryRegID) + .setParameter("visitID", visit.getBenVisitId()) + .setParameter("psm", visit.getProviderServiceMapID()) + .setParameter("visitCode", visit.getVisitCode()) + .setParameter("instruction", instruction) + .setParameter("user", createdBy) + .setParameter("van", vanID) + .setParameter("pp", parkingPlaceID) + .executeUpdate(); + long prescriptionID = lastInsertId(); + stampVanSerialNo("t_prescription", "PrescriptionID", prescriptionID); + return prescriptionID; + } + + /** + * Units to give when the app sends no quantity – MMU's rule (CommonNurseServiceImpl.calculateQtyPrescribed). + * Tablets/capsules: tablets per intake x intakes per day x days, rounded up. Tablets per intake come from the + * dose (Half Tab 0.5, One Tab 1, One & Half Tab 1.5, Two Tabs 2); no dose sent = one tablet (capsule always 1). + * Single/Stat dose = one intake. SOS = up to one intake a day (one intake when no duration). Once in a week = + * one intake per 7 days. Other forms (syrup, cream, injection, drops...): 1 bottle/tube/vial. Null when a + * tablet/capsule quantity cannot be worked out (unknown frequency or missing duration). + */ + private Integer calculateQuantity(Map drug, Integer formID) { + if (formID == null || (formID != 1 && formID != 2)) return 1; // 1 = Tablet, 2 = Capsule + double perIntake = formID == 2 ? 1 : tabletsPerIntake(toStr(drug.get("dose"))); + String frequency = Optional.ofNullable(toStr(drug.get("frequency"))).orElse("").toUpperCase(); + if (frequency.contains("SINGLE DOSE") || frequency.contains("STAT")) return (int) Math.ceil(perIntake); + + Integer duration = toInt(drug.get("duration")); + boolean sos = frequency.contains("SOS"); + if (duration == null || duration <= 0) return sos ? (int) Math.ceil(perIntake) : null; + String unit = Optional.ofNullable(toStr(drug.get("durationUnit"))).orElse("Day").toLowerCase(); + int days = unit.startsWith("week") ? duration * 7 : unit.startsWith("month") ? duration * 30 + : unit.startsWith("year") ? duration * 365 : duration; + + if (frequency.contains("WEEK")) return (int) Math.ceil(perIntake * ((days + 6) / 7)); // Once in a Week + int perDay; + if (frequency.contains("QID") || frequency.contains("FOUR")) perDay = 4; + else if (frequency.contains("TID") || frequency.contains("THRICE")) perDay = 3; + else if (frequency.contains("BD") || frequency.contains("TWICE")) perDay = 2; + else if (frequency.contains("OD") || frequency.contains("ONCE DAILY") || sos) perDay = 1; // SOS: up to 1 a day + else return null; // unknown frequency + return (int) Math.ceil(perIntake * perDay * days); + } + + // MMU dose names (m_drugdose); anything else or no dose = one tablet + private static double tabletsPerIntake(String dose) { + if (dose == null) return 1; + String d = dose.trim().toLowerCase(); + if (d.startsWith("half")) return 0.5; + if (d.startsWith("one & half") || d.startsWith("one and half") || d.startsWith("1.5")) return 1.5; + if (d.startsWith("two")) return 2; + return 1; + } + + // ItemID, ItemName, strength+UOM, form, route, isEDL, IssueType, ItemFormID — only if the drug is mapped to + // the store on its own service line (same join as v_drugforprescription, so the save accepts what the list shows). + private Object[] getStoreItem(Integer facilityID, Integer drugID) { + List rows = em.createNativeQuery("SELECT i.ItemID, i.ItemName, CONCAT_WS(' ', i.Strength, u.UOMName), " + + "f.ItemFormName, r.RouteName, i.isEDL, c.IssueType, i.ItemFormID " + + "FROM db_iemr.m_item i " + + "JOIN db_iemr.m_itemfacilitymapping m ON m.ItemID = i.ItemID AND m.FacilityID = :facilityID " + + "AND m.ProviderServiceMapID = i.ProviderServiceMapID AND m.Deleted = false " + + "LEFT JOIN db_iemr.m_uom u ON u.UOMID = i.UOMID " + + "LEFT JOIN db_iemr.m_itemform f ON f.ItemFormID = i.ItemFormID " + + "LEFT JOIN db_iemr.m_routeofadmin r ON r.RouteID = i.RouteID " + + "LEFT JOIN db_iemr.m_itemcategory c ON c.ItemCategoryID = i.ItemCategoryID " + + "WHERE i.ItemID = :drugID AND i.Deleted = false") + .setParameter("facilityID", facilityID) + .setParameter("drugID", drugID) + .getResultList(); + if (rows.isEmpty()) { + throw new IllegalArgumentException("drugID " + drugID + " is not available in this camp's store"); + } + return (Object[]) rows.get(0); + } + + // A batch is usable if it has no expiry date (counted by v_drugforprescription too) or lasts the course. + private static final String USABLE_BATCH = "AND (ExpiryDate IS NULL OR ExpiryDate > DATE_ADD(CURDATE(), INTERVAL :days DAY)) "; + + // Usable batches (VanSerialNo, QuantityInHand), locked for this transaction, in the category's issue order. + @SuppressWarnings("unchecked") + private List lockBatches(Integer facilityID, Integer drugID, String issueType, int durationDays) { + String order; + if ("Last In First Out".equalsIgnoreCase(issueType)) order = "CreatedDate DESC, ItemStockEntryID DESC"; + else if ("First In First Out".equalsIgnoreCase(issueType)) order = "CreatedDate ASC, ItemStockEntryID ASC"; + else order = "ExpiryDate IS NULL, ExpiryDate ASC, ItemStockEntryID ASC"; // First Expiry First Out (default) + Query q = em.createNativeQuery("SELECT VanSerialNo, QuantityInHand FROM db_iemr.t_itemstockentry " + + "WHERE FacilityID = :facilityID AND ItemID = :drugID AND Deleted = false AND QuantityInHand > 0 " + + USABLE_BATCH + "ORDER BY " + order + " FOR UPDATE") + .setParameter("facilityID", facilityID) + .setParameter("drugID", drugID) + .setParameter("days", durationDays); + return (List) q.getResultList(); + } + + private int availableQuantity(Integer facilityID, Integer drugID, int durationDays) { + Object v = em.createNativeQuery("SELECT COALESCE(SUM(QuantityInHand), 0) FROM db_iemr.t_itemstockentry " + + "WHERE FacilityID = :facilityID AND ItemID = :drugID AND Deleted = false AND QuantityInHand > 0 " + + USABLE_BATCH) + .setParameter("facilityID", facilityID) + .setParameter("drugID", drugID) + .setParameter("days", durationDays) + .getSingleResult(); + return ((Number) v).intValue(); + } + + private Integer getStorePsm(Integer facilityID) { + Object v = em.createNativeQuery("SELECT ProviderServiceMapID FROM db_iemr.m_facility WHERE FacilityID = :facilityID") + .setParameter("facilityID", facilityID) + .getSingleResult(); + return v != null ? ((Number) v).intValue() : null; + } + + // Longest treatment among the lines for this drug, so the batch lasts until the course ends. + private int maxDurationDays(List> drugs, Integer drugID) { + int max = 0; + for (Map d : drugs) { + if (!drugID.equals(toInt(d.get("drugID")))) continue; + Integer n = toInt(d.get("duration")); + if (n == null || n <= 0) continue; + String unit = Optional.ofNullable(toStr(d.get("durationUnit"))).orElse("Day").toLowerCase(); + int days = unit.startsWith("week") ? n * 7 : unit.startsWith("month") ? n * 30 : unit.startsWith("year") ? n * 365 : n; + max = Math.max(max, days); + } + return max; + } + + private long lastInsertId() { + return ((Number) em.createNativeQuery("SELECT LAST_INSERT_ID()").getSingleResult()).longValue(); + } + + // VanSerialNo = own ID, as every MMU/Inventory save does, so van-to-server sync can identify the row. + private void stampVanSerialNo(String table, String idColumn, long id) { + em.createNativeQuery("UPDATE db_iemr." + table + " SET VanSerialNo = " + idColumn + " WHERE " + idColumn + " = :id") + .setParameter("id", id) + .executeUpdate(); + } + + @SuppressWarnings("unchecked") + private List> toList(Object v) { + if (v instanceof List) return (List>) v; + return Collections.emptyList(); + } + + private Integer toInt(Object v) { + if (v == null) return null; + if (v instanceof Number) return ((Number) v).intValue(); + String s = v.toString().trim(); + if (s.isEmpty()) return null; + try { + return (int) Double.parseDouble(s); + } catch (NumberFormatException e) { + return null; + } + } + + private String toStr(Object v) { + return v != null ? v.toString() : null; + } +} diff --git a/src/main/java/com/iemr/flw/service/StopTBService.java b/src/main/java/com/iemr/flw/service/StopTBService.java index e549daa6f..4011d4315 100644 --- a/src/main/java/com/iemr/flw/service/StopTBService.java +++ b/src/main/java/com/iemr/flw/service/StopTBService.java @@ -1,5 +1,6 @@ package com.iemr.flw.service; +import java.util.Collection; import java.util.List; import java.util.Map; @@ -19,4 +20,15 @@ public interface StopTBService { // Nurse — Diagnostics List> saveDiagnostics(List> dataList) throws Exception; Map getAllDiagnostics(Integer providerServiceMapID, Integer villageID) throws Exception; + + // The same records the getAll endpoints return, for specific rows (STOP-TB changes feed), keyed by + // the row id. Deleted rows, and rows getAll would not return, are left out. + Map> getNurseTBScreeningsByIds(Collection ids); + Map> getGeneralExaminationsByIds(Collection ids); + Map> getDiagnosticsByIds(Collection ids); + // Standard-table OPD visits, keyed by BenVisitID + Map> getGeneralOpdByVisitIds(Collection benVisitIds); + // Older free-text OPD rows (tb_stoptb_general_opd), keyed by their id; left out when the same visit + // also exists in the standard tables, as getAllGeneralOpd does + Map> getLegacyGeneralOpdByIds(Collection ids); } diff --git a/src/main/java/com/iemr/flw/service/StopTBSyncService.java b/src/main/java/com/iemr/flw/service/StopTBSyncService.java new file mode 100644 index 000000000..9e8c94b6a --- /dev/null +++ b/src/main/java/com/iemr/flw/service/StopTBSyncService.java @@ -0,0 +1,19 @@ +package com.iemr.flw.service; + +import java.util.List; +import java.util.Map; + +public interface StopTBSyncService { + + /** + * One page of the STOP-TB changes feed for the given villages: a first full download when the + * token is missing, from another hub or too old; otherwise only what changed after it. + * Returns {supported:false} where the change log isn't installed, so the app keeps its full pull. + * The service and villages must be mapped to userId (from the JWT); empty villageIds means all of + * the user's villages for that service. + * types limits the feed to the record types the app applies (StopTBSyncType api names); null or + * empty means all. The app must start a new snapshot (no token) whenever it changes this list. + */ + Map getChanges(Integer userId, String token, Integer providerServiceMapID, List villageIds, + List types, Integer limit, String authorisation); +} diff --git a/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java b/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java index 23871f1b2..b1d4165eb 100644 --- a/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java +++ b/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java @@ -1,8 +1,6 @@ package com.iemr.flw.service; import com.iemr.flw.domain.iemr.TBConfirmedCaseDTO; -import com.iemr.flw.dto.iemr.TbReferralFollowUpDTO; -import com.iemr.flw.dto.iemr.TbTptFollowUpDTO; import org.springframework.stereotype.Service; import java.util.List; @@ -18,11 +16,6 @@ public interface TBConfirmedCaseService { String getByProviderServiceMapId(Integer providerServiceMapID, Integer villageID) throws Exception; - String saveReferralFollowUp(TbReferralFollowUpDTO requestDTO, String token); - - String getReferralFollowUp(String token); - - String saveTptFollowUp(TbTptFollowUpDTO requestDTO, String token); - - String getTptFollowUp(String token); + // One record per row, in the same JSON shape getAll returns (STOP-TB changes feed), keyed by id. + java.util.Map getRecordsByIds(java.util.Collection ids); } diff --git a/src/main/java/com/iemr/flw/service/TBSuspectedService.java b/src/main/java/com/iemr/flw/service/TBSuspectedService.java index 32d882a80..97d9e0d87 100644 --- a/src/main/java/com/iemr/flw/service/TBSuspectedService.java +++ b/src/main/java/com/iemr/flw/service/TBSuspectedService.java @@ -13,4 +13,7 @@ public interface TBSuspectedService { String save(TBSuspectedRequestDTO tbScreeningList) throws Exception; String getByUserId(GetBenRequestHandler userId); + + // One record per row, in the same JSON shape getAll returns (STOP-TB changes feed), keyed by id. + java.util.Map getRecordsByIds(java.util.Collection ids); } 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 dda2bb9d1..bc1edc372 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -119,6 +120,9 @@ public class BeneficiaryServiceImpl implements BeneficiaryService { @Autowired private BenPhysicalVitalRepo benPhysicalVitalRepo; + @Autowired + private WorklistPagePrefetch worklistPagePrefetch; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd-MM-yyyy"); @@ -141,8 +145,10 @@ public String getBenData(GetBenRequestHandler request, String authorisation) thr // O(village size), on every page including page 0. See BeneficiaryRepo for the // underlying query. if (request.getProviderServiceMapID() != null && request.getVillageID() != null) { + long tStart = System.currentTimeMillis(); long totalCount = beneficiaryRepo.countVillageWorklist( request.getProviderServiceMapID(), request.getVillageID()); + long tCount = System.currentTimeMillis(); if (totalCount == 0) return null; @@ -152,6 +158,7 @@ public String getBenData(GetBenRequestHandler request, String authorisation) thr List addressIds = beneficiaryRepo.getVillageWorklistAddressIds( request.getProviderServiceMapID(), request.getVillageID(), pageSize, offset); + long tIds = System.currentTimeMillis(); if (addressIds.isEmpty()) return null; @@ -159,17 +166,28 @@ public String getBenData(GetBenRequestHandler request, String authorisation) thr // Hibernate 6.4.1 bug there: "Binding is multi-valued; illegal call to // #getBindValue" even with a well-formed, non-empty id list (confirmed live // against 192.168.45.40 - the 10 rows returned have no NULLs/type issues, so - // this isn't a data problem). Looping getAddressById() per id sidesteps - // Hibernate's collection-parameter binding entirely - only ever pageSize (10) - // calls per page, same order-preservation as before. - List pageAddresses = addressIds.stream() - .map(beneficiaryRepo::getAddressById) - .filter(java.util.Objects::nonNull) - .collect(Collectors.toList()); + // this isn't a data problem). WorklistPagePrefetch reads them in one native query + // with the ids as numeric literals (no collection binding), keeping page order; + // the per-id loop stays as the fallback. + List pageAddresses; + try { + pageAddresses = worklistPagePrefetch.addressesInOrder(addressIds); + } catch (Exception e) { + logger.warn("Worklist address batch failed, reading one by one: " + e.getMessage()); + pageAddresses = addressIds.stream() + .map(beneficiaryRepo::getAddressById) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toList()); + } if (pageAddresses.isEmpty()) return null; + long tAddr = System.currentTimeMillis(); - return getMappingsForAddressIDs(pageAddresses, totalPage, authorisation); + String timingLabel = "[WORKLIST-TIMING] psm=" + request.getProviderServiceMapID() + + " village=" + request.getVillageID() + " page=" + request.getPageNo() + "/" + totalPage + + " pageSize=" + pageSize + " villageTotal=" + totalCount + + " | countMs=" + (tCount - tStart) + " idsMs=" + (tIds - tCount) + " addressMs=" + (tAddr - tIds); + return getMappingsForAddressIDs(pageAddresses, totalPage, authorisation, timingLabel, tStart); } // Normal FLW/ASHA path @@ -213,13 +231,59 @@ public String getBenData(GetBenRequestHandler request, String authorisation) thr return getMappingsForAddressIDs( pageResult.getContent(), pageResult.getTotalPages(), - authorisation + authorisation, + null, + 0L ); } + // timingLabel != null logs one [WORKLIST-TIMING] line per page (Stop TB village path) so camp-laptop + // logs show where each page's time goes. private String getMappingsForAddressIDs(List addressList, int totalPage, - String authorisation) { + String authorisation, String timingLabel, long tStart) { + long tBuildStart = System.currentTimeMillis(); + List> resultList = buildBeneficiaryRecords(addressList, authorisation); + long tBuild = System.currentTimeMillis(); + + Map response = new HashMap<>(); + response.put("data", resultList); + response.put("pageSize", Integer.parseInt(door_to_door_page_size)); + response.put("totalPage", totalPage); + Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy h:mm:ss a").create(); + String json = gson.toJson(response); + + if (timingLabel != null) { + long tEnd = System.currentTimeMillis(); + logger.info(timingLabel + " buildMs=" + (tBuild - tBuildStart) + " jsonMs=" + (tEnd - tBuild) + + " totalMs=" + (tEnd - tStart) + " rows=" + resultList.size() + "/" + addressList.size() + + " bytes=" + json.length()); + } + return json; + } + + // One record per beneficiary, in the same JSON shape getBeneficiaryData returns, keyed by BenRegId. + // Used by the STOP-TB changes feed; a BenRegId with no mapping or address is left out. + @Override + public Map getBeneficiaryRecordsByRegIds(List benRegIds, String authorisation) { + List addresses = new ArrayList<>(); + for (Long benRegId : benRegIds) { + RMNCHMBeneficiarymapping m = beneficiaryRepo.getById(BigInteger.valueOf(benRegId)); + if (m == null || m.getBenAddressId() == null) continue; + RMNCHMBeneficiaryaddress a = beneficiaryRepo.getAddressById(m.getBenAddressId()); + if (a != null) addresses.add(a); + } + Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy h:mm:ss a").create(); + Map records = new LinkedHashMap<>(); + for (Map record : buildBeneficiaryRecords(addresses, authorisation)) { + Object benRegId = record.get("BenRegId"); + if (benRegId != null) records.put(new BigInteger(benRegId.toString()).longValue(), gson.toJson(record)); + } + return records; + } + + private List> buildBeneficiaryRecords(List addressList, + String authorisation) { RMNCHHouseHoldDetails benHouseHoldRMNCH_ROBJ; RMNCHBeneficiaryDetailsRmnch benDetailsRMNCH_OBJ; RMNCHBornBirthDetails benBotnBirthRMNCH_ROBJ; @@ -233,10 +297,20 @@ private String getMappingsForAddressIDs(List addressLi Map resultMap; ArrayList> resultList = new ArrayList<>(); + // One query per table for the whole page; if it fails, every lookup below falls back to its + // original per-beneficiary query. + WorklistPagePrefetch.Page pf = null; + try { + pf = worklistPagePrefetch.load(addressList); + } catch (Exception e) { + logger.warn("Worklist page prefetch failed, using per-beneficiary queries: " + e.getMessage()); + } + for (RMNCHMBeneficiaryaddress a : addressList) { // exception by-passing try { - List mappingsForAddress = beneficiaryRepo.getByAddressID(a.getId()); + List mappingsForAddress = pf != null ? pf.mappings(a.getId()) + : beneficiaryRepo.getByAddressID(a.getId()); if(!mappingsForAddress.isEmpty()){ RMNCHMBeneficiarymapping m = mappingsForAddress.get(0); if (m != null) { @@ -249,38 +323,53 @@ private String getMappingsForAddressIDs(List addressLi benImageOBJ = new RMNCHMBeneficiaryImage(); benAddressOBJ = new RMNCHMBeneficiaryaddress(); benContactOBJ = new RMNCHMBeneficiarycontact(); - Map healthDetails = getBenHealthDetails(m.getBenRegId()); + Long regIdLong = m.getBenRegId() != null ? m.getBenRegId().longValue() : null; + // no m_benhealthidmapping row -> the original lookup returns an empty map too + Map healthDetails = (pf != null && regIdLong != null && !pf.hasHealthId(regIdLong)) + ? new HashMap<>() : getBenHealthDetails(m.getBenRegId()); if (m.getBenDetailsId() != null) { - benDetailsOBJ = beneficiaryRepo.getDetailsById(m.getBenDetailsId()); + benDetailsOBJ = pf != null ? pf.detail(m.getBenDetailsId()) + : beneficiaryRepo.getDetailsById(m.getBenDetailsId()); } if (m.getBenAccountID() != null) { - benAccountOBJ = beneficiaryRepo.getAccountById(m.getBenAccountID()); + benAccountOBJ = pf != null ? pf.account(m.getBenAccountID()) + : beneficiaryRepo.getAccountById(m.getBenAccountID()); } if (m.getBenImageId() != null) { - benImageOBJ = beneficiaryRepo.getImageById(m.getBenImageId().longValue()); + benImageOBJ = pf != null ? pf.image(m.getBenImageId()) + : beneficiaryRepo.getImageById(m.getBenImageId().longValue()); } if (m.getBenAddressId() != null) { - benAddressOBJ = beneficiaryRepo.getAddressById(m.getBenAddressId()); + benAddressOBJ = pf != null ? pf.address(m.getBenAddressId()) + : beneficiaryRepo.getAddressById(m.getBenAddressId()); } if (m.getBenContactsId() != null) { - benContactOBJ = beneficiaryRepo.getContactById(m.getBenContactsId()); + benContactOBJ = pf != null ? pf.contact(m.getBenContactsId()) + : beneficiaryRepo.getContactById(m.getBenContactsId()); } BigInteger benID = null; - if (m.getBenRegId() != null) - benID = beneficiaryRepo.getBenIdFromRegID(m.getBenRegId().longValue()); + if (regIdLong != null) { + // more than one row: let the original single-result query decide, as before + benID = (pf != null && pf.benIds(regIdLong).size() <= 1) + ? pf.benIds(regIdLong).stream().findFirst().orElse(null) + : beneficiaryRepo.getBenIdFromRegID(regIdLong); + } - if (m.getBenRegId() != null) { - List detailsByRegID = beneficiaryRepo - .getDetailsByRegID((m.getBenRegId()).longValue()); + if (regIdLong != null) { + List detailsByRegID = pf != null ? pf.rmnch(regIdLong) + : beneficiaryRepo.getDetailsByRegID(regIdLong); if(!detailsByRegID.isEmpty()){ benDetailsRMNCH_OBJ = detailsByRegID.get(0); } - benBotnBirthRMNCH_ROBJ = beneficiaryRepo.getBornBirthByRegID((m.getBenRegId()).longValue()); + benBotnBirthRMNCH_ROBJ = (pf != null && pf.bornBirth(regIdLong).size() <= 1) + ? pf.bornBirth(regIdLong).stream().findFirst().orElse(null) + : beneficiaryRepo.getBornBirthByRegID(regIdLong); if (benDetailsRMNCH_OBJ != null && benDetailsRMNCH_OBJ.getHouseoldId() != null) { - List householdsById = houseHoldRepo - .getByHouseHoldID(benDetailsRMNCH_OBJ.getHouseoldId()); + List householdsById = pf != null + ? pf.households(benDetailsRMNCH_OBJ.getHouseoldId()) + : houseHoldRepo.getByHouseHoldID(benDetailsRMNCH_OBJ.getHouseoldId()); if(!householdsById.isEmpty()){ benHouseHoldRMNCH_ROBJ = householdsById.get(0); } @@ -363,7 +452,6 @@ private String getMappingsForAddressIDs(List addressLi if (benAddressOBJ.getPermPinCode() != null) benDetailsRMNCH_OBJ.setPinCode(benAddressOBJ.getPermPinCode()); - // ----------------------------------------------------------------------------- // related benids @@ -393,7 +481,8 @@ private String getMappingsForAddressIDs(List addressLi } else { // i_beneficiarydetails.dob is null for Stop TB mobile registrations // (Identity-API mapper commented out) — fall back to i_ben_flow_outreach.ben_dob - List flows = benFlowStatusRepo.findByBeneficiaryRegID(m.getBenRegId().longValue()); + List flows = pf != null ? pf.flows(regIdLong) + : benFlowStatusRepo.findByBeneficiaryRegID(m.getBenRegId().longValue()); if (!flows.isEmpty() && flows.get(0).getDob() != null) benDetailsRMNCH_OBJ.setDob(flows.get(0).getDob()); } @@ -530,39 +619,39 @@ private String getMappingsForAddressIDs(List addressLi if (benDetailsOBJ != null && benDetailsOBJ.getOccupation() != null) benDetailsRMNCH_OBJ.setOccupation(benDetailsOBJ.getOccupation()); - // anthropometry from t_phy_anthropometry, vitals from t_phy_vitals - // fallback to otherFields if exam not yet saved for this beneficiary + // anthropometry from t_phy_anthropometry, vitals from t_phy_vitals — latest non-null + // value per field, so a later row with some columns empty doesn't hide older values. + // otherFields fills whatever is still missing (beneficiaries registered before + // Identity-API started writing these tables). Long benRegIdLong = m.getBenRegId() != null ? m.getBenRegId().longValue() : null; if (benRegIdLong != null) { - List anthroList = - benAnthropometryRepo.findByBeneficiaryRegIDOrderByCreatedDateDesc(benRegIdLong); - List vitalList = - benPhysicalVitalRepo.findByBeneficiaryRegIDOrderByCreatedDateDesc(benRegIdLong); + List anthroList = pf != null ? pf.anthropometry(benRegIdLong) + : benAnthropometryRepo.findByBeneficiaryRegIDOrderByCreatedDateDesc(benRegIdLong); + List vitalList = pf != null ? pf.vitals(benRegIdLong) + : benPhysicalVitalRepo.findByBeneficiaryRegIDOrderByCreatedDateDesc(benRegIdLong); Map anthropometry = new HashMap<>(); - if (!anthroList.isEmpty()) { - BenAnthropometryDetail anthro = anthroList.get(0); - if (anthro.getHeightCm() != null) anthropometry.put("height", anthro.getHeightCm()); - if (anthro.getWeightKg() != null) anthropometry.put("weight", anthro.getWeightKg()); - if (anthro.getBmi() != null) anthropometry.put("bmi", anthro.getBmi()); + for (BenAnthropometryDetail anthro : anthroList) { + if (anthro.getHeightCm() != null) anthropometry.putIfAbsent("height", anthro.getHeightCm()); + if (anthro.getWeightKg() != null) anthropometry.putIfAbsent("weight", anthro.getWeightKg()); + if (anthro.getBmi() != null) anthropometry.putIfAbsent("bmi", anthro.getBmi()); } - if (!vitalList.isEmpty()) { - BenPhysicalVitalDetail vital = vitalList.get(0); - if (vital.getTemperature() != null) anthropometry.put("temperatureValue", vital.getTemperature()); - if (vital.getPulseRate() != null) anthropometry.put("pulseRate", vital.getPulseRate()); - if (vital.getSystolicBP() != null) anthropometry.put("systolicBP", vital.getSystolicBP()); - if (vital.getDiastolicBP() != null) anthropometry.put("diastolicBP", vital.getDiastolicBP()); - if (vital.getBloodGlucoseRandom() != null) anthropometry.put("bloodGlucoseRandom", vital.getBloodGlucoseRandom()); + for (BenPhysicalVitalDetail vital : vitalList) { + if (vital.getTemperature() != null) anthropometry.putIfAbsent("temperatureValue", vital.getTemperature()); + if (vital.getPulseRate() != null) anthropometry.putIfAbsent("pulseRate", vital.getPulseRate()); + if (vital.getSystolicBP() != null) anthropometry.putIfAbsent("systolicBP", vital.getSystolicBP()); + if (vital.getDiastolicBP() != null) anthropometry.putIfAbsent("diastolicBP", vital.getDiastolicBP()); + if (vital.getBloodGlucoseRandom() != null) anthropometry.putIfAbsent("bloodGlucoseRandom", vital.getBloodGlucoseRandom()); } - // fallback: if no exam saved yet, read from registration otherFields - if (anthropometry.isEmpty() && benDetailsOBJ != null && benDetailsOBJ.getOtherFields() != null) { + // fallback per field: registration otherFields + if (benDetailsOBJ != null && benDetailsOBJ.getOtherFields() != null) { try { Map extraFields = new Gson().fromJson(benDetailsOBJ.getOtherFields(), Map.class); - if (extraFields.containsKey("weight")) anthropometry.put("weight", extraFields.get("weight")); - if (extraFields.containsKey("height")) anthropometry.put("height", extraFields.get("height")); - if (extraFields.containsKey("bmi")) anthropometry.put("bmi", extraFields.get("bmi")); - if (extraFields.containsKey("temperatureValue")) anthropometry.put("temperatureValue", extraFields.get("temperatureValue")); + if (extraFields.get("weight") != null) anthropometry.putIfAbsent("weight", extraFields.get("weight")); + if (extraFields.get("height") != null) anthropometry.putIfAbsent("height", extraFields.get("height")); + if (extraFields.get("bmi") != null) anthropometry.putIfAbsent("bmi", extraFields.get("bmi")); + if (extraFields.get("temperatureValue") != null) anthropometry.putIfAbsent("temperatureValue", extraFields.get("temperatureValue")); } catch (Exception ex) { logger.warn("Could not parse otherFields for fallback anthropometry: " + benDetailsOBJ.getBeneficiaryDetailsId()); } @@ -595,13 +684,24 @@ private String getMappingsForAddressIDs(List addressLi // adding asha id / created by - user id if (benAddressOBJ.getCreatedBy() != null) { - Integer userID = beneficiaryRepo.getUserIDByUserName(benAddressOBJ.getCreatedBy()); + String createdBy = benAddressOBJ.getCreatedBy(); + Integer userID; + if (pf != null && pf.userIdCache().containsKey(createdBy)) { + userID = pf.userIdCache().get(createdBy); + } else { + userID = beneficiaryRepo.getUserIDByUserName(createdBy); + if (pf != null) pf.userIdCache().put(createdBy, userID); + } if (userID != null && userID > 0) resultMap.put("ashaId", userID); } - // get HealthID of ben - if (m.getBenRegId() != null) { - fetchHealthIdByBenRegID(m.getBenRegId().longValue(), authorisation, resultMap); + // get HealthID of ben: read locally (same table FHIR-API reads), only when a mapping exists + if (regIdLong != null) { + if (pf != null) { + if (pf.hasHealthId(regIdLong)) putLocalHealthId(regIdLong, resultMap); + } else { + fetchHealthIdByBenRegID(regIdLong, authorisation, resultMap); + } } resultList.add(resultMap); @@ -617,13 +717,7 @@ private String getMappingsForAddressIDs(List addressLi logger.error("error for addressID :" + e.getMessage() + a.getId() + " and vanID : " + a.getVanID()); } } - - Map response = new HashMap<>(); - response.put("data", resultList); - response.put("pageSize", Integer.parseInt(door_to_door_page_size)); - response.put("totalPage", totalPage); - Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy h:mm:ss a").create(); - return gson.toJson(response); + return resultList; } @@ -671,6 +765,22 @@ private Map getBenBenVisitDetails(BigInteger benRegId) { return healthDetails; } + // Mirrors FHIR-API HealthIDServiceImpl.getBenHealthID: every m_benhealthidmapping row, with healthId + // replaced by t_healthid.HealthId when that is filled; the last row wins, as in fetchHealthIdByBenRegID. + private void putLocalHealthId(Long benRegID, Map resultMap) { + for (Object[] row : beneficiaryRepo.getHealthIdMappingRows(benRegID)) { + String healthId = row[0] != null ? row[0].toString() : null; + String healthIdNumber = row[1] != null ? row[1].toString() : null; + if (healthIdNumber != null) { + List fromHealthIdTable = beneficiaryRepo.getHealthIdByNumber(healthIdNumber); + if (!fromHealthIdTable.isEmpty() && fromHealthIdTable.get(0) != null && !fromHealthIdTable.get(0).isEmpty()) + healthId = fromHealthIdTable.get(0); + } + if (healthId != null) resultMap.put("healthId", healthId); + if (healthIdNumber != null) resultMap.put("healthIdNumber", healthIdNumber); + } + } + public void fetchHealthIdByBenRegID(Long benRegID, String authorization, Map resultMap) { Map requestMap = new HashMap(); requestMap.put("beneficiaryRegID", benRegID); diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java index b0f92ab4e..6c0f1f586 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -116,7 +116,7 @@ public DiagnosticDocumentContent fetch(Long beneficiaryId, DiagnosticDocumentTyp String orderType = documentType.impliedOrderType().name(); DiagnosticOrder order; if (visitCode != null) { - order = diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType) + order = diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType) .orElseThrow(() -> new Exception("No diagnostic order found for beneficiaryId=" + beneficiaryId + ", visitCode=" + visitCode + ", orderType=" + orderType)); } else { diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 1485843e6..913a747a2 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -1,13 +1,16 @@ package com.iemr.flw.service.impl; +import com.iemr.flw.domain.iemr.BenVisitDetail; import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.domain.iemr.DiagnosticResult; import com.iemr.flw.domain.iemr.TBSuspected; +import com.iemr.flw.domain.iemr.User; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; import com.iemr.flw.dto.ManualDiagnosticResultRequestDto; import com.iemr.flw.dto.VendorHealthDto; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -15,12 +18,19 @@ import com.iemr.flw.integration.provider.DiagnosticPushResult; import com.iemr.flw.masterEnum.DiagnosticOrderStatus; import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.repo.identity.BeneficiaryRepo; import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.repo.iemr.DiagnosticResultRepo; +import com.iemr.flw.repo.iemr.EmployeeMasterRepo; import com.iemr.flw.repo.iemr.TBSuspectedRepo; import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.DiagnosticDocumentService; import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.service.TBStopVisitService; +import com.iemr.flw.utils.JwtUtil; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -28,9 +38,15 @@ import org.springframework.stereotype.Service; import java.sql.Timestamp; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; @Service public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @@ -39,11 +55,27 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { private static final Set BLOCKING_STATUSES = Set.of( DiagnosticOrderStatus.PENDING.name(), - DiagnosticOrderStatus.IN_PROGRESS.name(), - DiagnosticOrderStatus.EXPIRED.name(), DiagnosticOrderStatus.COMPLETED.name(), DiagnosticOrderStatus.MANUAL_ENTRY.name()); + private static final Set NON_REUSABLE_ON_CLOSE_STATUSES = Set.of( + DiagnosticOrderStatus.COMPLETED.name(), + DiagnosticOrderStatus.FAILED.name(), + DiagnosticOrderStatus.CLOSED.name()); + + // Statuses the scheduler polls and the daily cutoff expires — matches findXrayDueForPoll / + // findTrueNatDueForPoll / findAllOutstandingOrders. + private static final Set OUTSTANDING_STATUSES = Set.of( + DiagnosticOrderStatus.PENDING.name(), + DiagnosticOrderStatus.IN_PROGRESS.name()); + + // Invalid test outcomes: the result is still recorded (result row, tb_suspected write-back, documents) + // but the order is CLOSED instead of COMPLETED, so a retest can be pushed. The vendor reports an + // invalid MTB/MDR_RIF run as "Error-2"; a manual entry uses "Invalid/Error". + private static final String XRAY_INVALID_RESULT = "AI Invalid Result"; + private static final String SPUTUM_INVALID_POLLED_RESULT = "Error-2"; + private static final String SPUTUM_INVALID_MANUAL_RESULT = "Invalid/Error"; + @Autowired private DiagnosticOrderRepo diagnosticOrderRepo; @@ -62,10 +94,109 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @Autowired private CampConfigService campConfigService; + @Autowired + private BeneficiaryRepo beneficiaryRepo; + + @Autowired + private TBStopVisitService tbStopVisitService; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private EmployeeMasterRepo employeeMasterRepo; + @Override - public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { + public DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception { + return createAndPushOrder(request, resolveActingUserName(jwtToken)); + } + + private String resolveActingUserName(String jwtToken) { + try { + User user = employeeMasterRepo.findUserByUserID(jwtUtil.extractUserId(jwtToken)); + if (user != null && user.getUserName() != null && !user.getUserName().isBlank()) { + return user.getUserName(); + } + } catch (Exception e) { + logger.warn("Could not resolve acting user's username from m_user: {}", e.getMessage()); + } + return jwtUtil.extractUsername(jwtToken); + } + + @Override + public DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto request) throws Exception { + return createAndPushOrder(request, "SYSTEM"); + } + + // One lock per beneficiary, held across visit lookup → dedup check → insert → vendor push. Without it, + // two simultaneous pushes for the same beneficiary could both pass the dedup check (or both create a + // visit for today) and send the same patient to the vendor twice — shown as duplicate names on the + // TrueNat machine. Keyed by beneficiary, not orderType, because the visit is shared across order types. + // (release-3.9.3 keys its lock by beneficiary+orderType because there the client supplies the + // visitCode; here the server creates the visit, so the key must be the beneficiary alone.) + // In-memory, so it only serialises within this JVM: correct while orders are created only by the + // single van server that can reach the vendor. Same mechanism as release-3.9.3: one ReentrantLock per + // beneficiary, held weakly so an idle beneficiary's lock is garbage-collected — any thread waiting on + // or holding it keeps a strong reference, so concurrent callers always share the same instance. + // User requests wait at most LOCK_WAIT_SECONDS and then fail with a retry message, instead of piling + // up behind a slow vendor push (bounded by the EmrLiteRestTemplateConfig timeouts). + private final LoadingCache beneficiaryOrderLocks = CacheBuilder.newBuilder() + .weakValues() + .build(CacheLoader.from(beneficiaryId -> new ReentrantLock())); + + private static final long LOCK_WAIT_SECONDS = 30; + + private T withBeneficiaryLock(Long beneficiaryId, Callable action) throws Exception { + Lock lock = beneficiaryLock(beneficiaryId); + if (!lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("Another diagnostic order operation is still in progress for beneficiaryId=" + + beneficiaryId + " — please retry shortly"); + } + try { + return action.call(); + } finally { + lock.unlock(); + } + } + + // For background work with no caller to retry it (the automatic retest): waits as long as it takes. + private T withBeneficiaryLockBlocking(Long beneficiaryId, Callable action) throws Exception { + Lock lock = beneficiaryLock(beneficiaryId); + lock.lock(); + try { + return action.call(); + } finally { + lock.unlock(); + } + } + + // For the poll scheduler: never waits. A busy beneficiary (user push/manual entry in progress) is + // simply left for the next tick, rather than stalling the single scheduler thread behind it. + private Optional withBeneficiaryLockIfFree(Long beneficiaryId, Callable action) throws Exception { + Lock lock = beneficiaryLock(beneficiaryId); + if (!lock.tryLock()) { + return Optional.empty(); + } + try { + return Optional.ofNullable(action.call()); + } finally { + lock.unlock(); + } + } + + private Lock beneficiaryLock(Long beneficiaryId) { + if (beneficiaryId == null) { + throw new IllegalArgumentException("beneficiaryId is required"); + } + return beneficiaryOrderLocks.getUnchecked(beneficiaryId); + } + + private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, String createdBy) throws Exception { + return withBeneficiaryLock(request.getBeneficiaryId(), () -> createAndPushOrderLocked(request, createdBy)); + } + + private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto request, String createdBy) throws Exception { Long beneficiaryId = request.getBeneficiaryId(); - Long visitCode = request.getVisitCode(); DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); String orderEvent = request.getOrderEvent(); String patientFirstName = request.getPatient().getFirstName(); @@ -73,24 +204,43 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr String patientDateOfBirth = request.getPatient().getDateOfBirth(); String patientSex = request.getPatient().getSex(); - String providerCode = providerFactory.getProviderCodeForOrderType(orderType); - String externalOrderId = String.format("%d-%d-%s", beneficiaryId, visitCode, orderType.name()); - - String reasonForRefusal = request.getReasonForRefusal(); - if (reasonForRefusal != null) { - return saveRefusedOrder(beneficiaryId, visitCode, orderType, orderEvent, providerCode, externalOrderId, - patientFirstName, patientLastName, patientDateOfBirth, patientSex, reasonForRefusal); + String reasonToClose = request.getReasonToClose(); + if (reasonToClose != null) { + return closeOrder(beneficiaryId, orderType, orderEvent, patientFirstName, patientLastName, + patientDateOfBirth, patientSex, reasonToClose, createdBy); } + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); + + Long beneficiaryRegID = beneficiaryRepo.getRegIDFromBenId(beneficiaryId); + if (beneficiaryRegID == null) { + throw new Exception("No beneficiaryRegID found for beneficiaryId=" + beneficiaryId); + } + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, null, createdBy, vanID, + parkingPlaceID); + Long visitCode = visit.getVisitCode(); + + String providerCode = providerFactory.getProviderCodeForOrderType(orderType); + String externalOrderId = String.format("%s-%d-%s", UUID.randomUUID(), visitCode, orderType.name()); + Optional latestForType = diagnosticOrderRepo .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()); Optional existing = (latestForType.isPresent() && visitCode.equals(latestForType.get().getVisitCode())) ? latestForType - : diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); + : diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); - if (existing.isPresent() && !DiagnosticOrderStatus.FAILED.name().equals(existing.get().getStatus())) { - return existing.get(); + if (existing.isPresent()) { + String existingStatus = existing.get().getStatus(); + if (!DiagnosticOrderStatus.FAILED.name().equals(existingStatus) + && !DiagnosticOrderStatus.CLOSED.name().equals(existingStatus)) { + // Still active or already resolved (PENDING/COMPLETED/legacy IN_PROGRESS/MANUAL_ENTRY) + // — returned as-is. + return existing.get(); + } + // FAILED and CLOSED are never reused, even for the same visit — both stay as-is for + // history, and a fresh push always creates a brand new row instead of overwriting either. } if (latestForType.isPresent() @@ -98,16 +248,16 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr && !visitCode.equals(latestForType.get().getVisitCode())) { DiagnosticOrder blocker = latestForType.get(); logger.info("Duplicate order push suppressed for beneficiaryId={}, orderType={}: unresolved order id={} " - + "(visitCode={}, status={}) already exists — returning it instead of pushing a new order for visitCode={}", + + "(visitCode={}, status={}) already exists — returning it instead of pushing a new order for visitCode={}", beneficiaryId, orderType, blocker.getId(), blocker.getVisitCode(), blocker.getStatus(), visitCode); return blocker; } - DiagnosticOrder order = existing.orElseGet(DiagnosticOrder::new); - if (order.getVanID() == null) { - order.setVanID(campConfigService.getVanID()); - order.setParkingPlaceID(campConfigService.getParkingPlaceID()); - } + // Neither FAILED nor CLOSED is ever reused above, so this is always a brand new row — a + // beneficiary's diagnostic order history is a sequence of rows, not one row overwritten in place. + DiagnosticOrder order = new DiagnosticOrder(); + order.setVanID(vanID); + order.setParkingPlaceID(parkingPlaceID); order.setOrderEvent(orderEvent); order.setBeneficiaryId(beneficiaryId); order.setVisitCode(visitCode); @@ -115,20 +265,20 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr order.setProviderCode(providerCode); order.setOrderType(orderType.name()); order.setExternalOrderId(externalOrderId); - // Reset required when reusing a previously-FAILED row, otherwise a successful retry would - // leave status=FAILED and findDueForPoll (PENDING/IN_PROGRESS only) would never poll it. - order.setStatus(DiagnosticOrderStatus.PENDING.name()); - order.setErrorMessage(null); + boolean noVendor = providerCode == null || providerCode.isBlank(); + order.setStatus(noVendor ? DiagnosticOrderStatus.MANUAL_ENTRY.name() : DiagnosticOrderStatus.PENDING.name()); order.setPatientFirstName(patientFirstName); order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); + order.setCreatedBy(createdBy); + order.setModifiedBy(createdBy); try { order = diagnosticOrderRepo.save(order); } catch (DataIntegrityViolationException dive) { Optional winner = diagnosticOrderRepo - .findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); + .findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); if (winner.isPresent()) { logger.warn("Lost create race for beneficiaryId={}, visitCode={}, orderType={} — returning existing order id={}", beneficiaryId, visitCode, orderType, winner.get().getId()); @@ -137,15 +287,21 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr throw dive; } - if (providerCode == null || providerCode.isBlank()) { + if (noVendor) { logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry", orderType, beneficiaryId); - order.setStatus(DiagnosticOrderStatus.MANUAL_ENTRY.name()); + // Status is MANUAL_ENTRY (set above) — awaiting manual entry via submitManualResult. order = diagnosticOrderRepo.save(order); if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); return order; } + return pushToProvider(order, providerCode); + } + + // Pushes an already-saved PENDING order to the vendor and saves the outcome — FAILED (with the + // error) if the push is rejected or throws. Shared by createAndPushOrder and pushRetestOrder. + private DiagnosticOrder pushToProvider(DiagnosticOrder order, String providerCode) { try { DiagnosticProvider provider = providerFactory.getProvider(providerCode); DiagnosticPushResult pushResult = provider.pushOrder(order); @@ -171,16 +327,57 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr return order; } + // Shared by createAndPushOrder (push with reasonToClose) and submitManualResult (manualResult + // with reasonToClose) — resolves the same visit/provider/externalOrderId a normal push would, then + // closes the order via saveRefusedOrder. Identical outcome regardless of which endpoint triggered it. + private DiagnosticOrder closeOrder(Long beneficiaryId, DiagnosticOrderType orderType, String orderEvent, + String patientFirstName, String patientLastName, String patientDateOfBirth, String patientSex, + String reasonToClose, String actingUserId) throws Exception { + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); + + Long beneficiaryRegID = beneficiaryRepo.getRegIDFromBenId(beneficiaryId); + if (beneficiaryRegID == null) { + throw new Exception("No beneficiaryRegID found for beneficiaryId=" + beneficiaryId); + } + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, null, actingUserId, vanID, + parkingPlaceID); + Long visitCode = visit.getVisitCode(); + + String providerCode = providerFactory.getProviderCodeForOrderType(orderType); + String externalOrderId = String.format("%s-%d-%s", UUID.randomUUID(), visitCode, orderType.name()); + return saveRefusedOrder(beneficiaryId, visitCode, orderType, orderEvent, providerCode, externalOrderId, + patientFirstName, patientLastName, patientDateOfBirth, patientSex, reasonToClose, actingUserId); + } + + // manualResult's reasonToClose variant has no patient/orderEvent in its request — it sources them + // from the beneficiary's own most recent order for this orderType (whatever its status), since that + // information already exists there. No prior order at all means there's nothing to source it from. + private DiagnosticOrder closeOrderManually(Long beneficiaryId, DiagnosticOrderType orderType, + String reasonToClose, String actingUserId) throws Exception { + return withBeneficiaryLock(beneficiaryId, () -> { + DiagnosticOrder source = diagnosticOrderRepo + .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()) + .orElseThrow(() -> new Exception("No diagnostic order found for beneficiaryId=" + beneficiaryId + + ", orderType=" + orderType.name() + " — cannot close a record that was never created")); + return closeOrder(beneficiaryId, orderType, source.getOrderEvent(), source.getPatientFirstName(), + source.getPatientLastName(), source.getPatientDateOfBirth(), source.getPatientSex(), reasonToClose, + actingUserId); + }); + } + // Refusals are keyed to the latest order for this beneficiary+orderType (not the exact visitCode // of this request), since a refusal can be recorded outside the visit that originally created the - // order. A COMPLETED latest order is left untouched (treated as "not found") and a new REFUSED row - // is created instead. Refused orders are saved as-is and never pushed to the vendor. + // order. A COMPLETED, FAILED or already-CLOSED latest order is left untouched (treated as "not found") + // and a new CLOSED row is created instead, so its history (e.g. a FAILED row's errorMessage) survives. + // Refused orders are saved as-is and never pushed to the vendor. private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, DiagnosticOrderType orderType, - String orderEvent, String providerCode, String externalOrderId, String patientFirstName, - String patientLastName, String patientDateOfBirth, String patientSex, String reasonForRefusal) { + String orderEvent, String providerCode, String externalOrderId, String patientFirstName, + String patientLastName, String patientDateOfBirth, String patientSex, String reasonToClose, + String actingUserId) { Optional latest = diagnosticOrderRepo .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()); - if (latest.isPresent() && DiagnosticOrderStatus.COMPLETED.name().equals(latest.get().getStatus())) { + if (latest.isPresent() && NON_REUSABLE_ON_CLOSE_STATUSES.contains(latest.get().getStatus())) { latest = Optional.empty(); } @@ -201,18 +398,23 @@ private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, Dia order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); + order.setCreatedBy(actingUserId); } - order.setStatus(DiagnosticOrderStatus.REFUSED.name()); - order.setReasonToClose(reasonForRefusal); + order.setStatus(DiagnosticOrderStatus.CLOSED.name()); + order.setReasonToClose(reasonToClose); order.setErrorMessage(null); + order.setModifiedBy(actingUserId); + order.setManuallyEnteredBy(actingUserId); + order.setProcessed("N"); try { order = diagnosticOrderRepo.save(order); if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); + notifyProviderOrderClosed(order, reasonToClose); return order; } catch (DataIntegrityViolationException dive) { Optional winner = diagnosticOrderRepo - .findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); + .findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); if (winner.isPresent()) { logger.warn("Lost create race for beneficiaryId={}, visitCode={}, orderType={} — returning existing order id={}", beneficiaryId, visitCode, orderType, winner.get().getId()); @@ -222,10 +424,58 @@ private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, Dia } } + @Override + public void notifyProviderOrderClosed(DiagnosticOrder order, String reason) { + String providerCode = order.getProviderCode(); + if (providerCode == null || providerCode.isBlank()) { + return; // no vendor was ever involved (manual-entry order) + } + if (order.getPushResponseJson() == null || order.getPushResponseJson().isEmpty()) { + return; // never actually confirmed as pushed to the vendor — nothing to cancel there + } + try { + DiagnosticCancelResult result = providerFactory.getProvider(providerCode).cancelOrder(order, reason); + order.setCancelResponseJson(result.getRawResponseJson()); + order.setProcessed("N"); + diagnosticOrderRepo.save(order); + } catch (Exception e) { + // Transport-level failure only (unreachable, timeout) — a provider-level rejection is + // already captured in the returned result above, not thrown. + logger.warn("Failed to notify provider of order cancellation for externalOrderId={}: {}", + order.getExternalOrderId(), e.getMessage()); + } + } + @Override public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { + return processResult(order, pollResult, false, "SYSTEM"); + } + + private DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult, + boolean writeBackWhenClosed, String actingUser) throws Exception { Optional existingResult = diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()); + // A poll that brings nothing new must not rewrite the rows: every rewrite resets processed + // (so MMU re-uploads the order) and would re-send it to every tablet. + boolean resultUnchanged = existingResult.isPresent() && sameResult(existingResult.get(), pollResult); + boolean orderUnchanged = sameOrderStatus(order, pollResult); DiagnosticResult result = existingResult.orElseGet(DiagnosticResult::new); + if (resultUnchanged && orderUnchanged && !writeBackWhenClosed) { + ingestAssets(order, pollResult); + diagnosticOrderRepo.recordUnchangedPoll(order.getId(), new Timestamp(System.currentTimeMillis()), + order.getRetryCount()); + DiagnosticOrderResultDto unchanged = new DiagnosticOrderResultDto(); + unchanged.setExternalOrderId(order.getExternalOrderId()); + unchanged.setOrderType(order.getOrderType()); + unchanged.setStatus(order.getStatus()); + unchanged.setProviderStatus(result.getProviderStatus()); + unchanged.setResultSummary(result.getResultSummary()); + unchanged.setErrorMessage(order.getErrorMessage()); + unchanged.setReasonToClose(order.getReasonToClose()); + unchanged.setTbPresence(result.getTbPresence()); + unchanged.setTbConfidence(result.getTbConfidence()); + unchanged.setDrugResistancePresence(result.getDrugResistancePresence()); + return unchanged; + } result.setExternalOrderId(order.getExternalOrderId()); result.setBeneficiaryId(order.getBeneficiaryId()); result.setProviderStatus(pollResult.getStatus().name()); @@ -239,7 +489,8 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP result.setTbPresence(pollResult.getTbPresence()); result.setTbConfidence(pollResult.getTbConfidence()); result.setDrugResistancePresence(pollResult.getDrugResistancePresence()); - result.setCreatedBy("SYSTEM"); + result.setCreatedBy(actingUser); + result.setModifiedBy(actingUser); if (result.getVanID() == null) { // Inherit from the parent order rather than re-reading Redis — the result belongs // to whichever van originated the order, not whichever van happens to be polling now. @@ -250,6 +501,9 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP diagnosticResultRepo.save(result); if (result.getVanSerialNo() == null) diagnosticResultRepo.updateVanSerialNo(result.getId()); } catch (DataIntegrityViolationException dive) { + // Only a first insert can lose a race (unique external_order_id). Any other integrity + // failure means the result was not saved, so the order must not move on without it. + if (existingResult.isPresent()) throw dive; logger.warn("Lost result upsert race for externalOrderId={}", order.getExternalOrderId()); result = diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()).orElse(result); } @@ -262,9 +516,12 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP order.setProviderOrderId(pollResult.getProviderOrderId()); } order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setModifiedBy(actingUser); + order.setProcessed("N"); diagnosticOrderRepo.save(order); - if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { + if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus()) + || (writeBackWhenClosed && DiagnosticOrderStatus.CLOSED.name().equals(order.getStatus()))) { recordTbSuspectedResult(order, result); } @@ -273,7 +530,7 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP dto.setOrderType(order.getOrderType()); dto.setStatus(order.getStatus()); dto.setErrorMessage(order.getErrorMessage()); - dto.setReasonForRefusal(order.getReasonToClose()); + dto.setReasonToClose(order.getReasonToClose()); dto.setProviderStatus(result.getProviderStatus()); dto.setResultSummary(result.getResultSummary()); dto.setTbPresence(result.getTbPresence()); @@ -329,8 +586,90 @@ private void ingestAssets(DiagnosticOrder order, DiagnosticPollResult pollResult } } + // The scheduler loads its due-for-poll batch at the start of a tick and polls each order in turn, so + // by the time an order's turn comes it may have been completed/closed by a manual entry. Polling and + // saving that stale copy would overwrite the manual result (whole-row save, no @Version), so the + // order is re-read under the beneficiary lock and skipped unless it is still awaiting a result. + // Failures are recorded on that same fresh row, inside the lock, for the same reason. @Override public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { + Optional polled = withBeneficiaryLockIfFree(order.getBeneficiaryId(), () -> { + Optional current = findOutstandingOrder(order.getId()); + if (current.isEmpty()) { + logger.info("Skipping poll for orderId={}: no longer PENDING/IN_PROGRESS", order.getId()); + return null; + } + DiagnosticOrder fresh = current.get(); + try { + return pollOnceLocked(fresh); + } catch (Exception e) { + int retries = (fresh.getRetryCount() == null ? 0 : fresh.getRetryCount()) + 1; + if (java.util.Objects.equals(fresh.getErrorMessage(), e.getMessage())) { + // Same failure as last time: only the attempt is recorded (see recordUnchangedPoll). + diagnosticOrderRepo.recordUnchangedPoll(fresh.getId(), new Timestamp(System.currentTimeMillis()), retries); + } else { + fresh.setRetryCount(retries); + fresh.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + fresh.setErrorMessage(e.getMessage()); + fresh.setModifiedBy("SYSTEM"); + fresh.setProcessed("N"); + diagnosticOrderRepo.save(fresh); + } + throw e; + } + }); + if (polled.isEmpty()) { + logger.debug("Poll for orderId={} skipped or deferred (beneficiary busy / order resolved)", order.getId()); + } + return polled.orElse(null); + } + + // Same stale-copy problem as pollOnce: the daily expiry batch is loaded up front, so each order is + // re-read under the lock and only closed if it is still outstanding. + @Override + public void expireOrder(DiagnosticOrder order, String reason) throws Exception { + withBeneficiaryLock(order.getBeneficiaryId(), () -> { + Optional current = findOutstandingOrder(order.getId()); + if (current.isEmpty()) { + logger.info("Skipping expiry for orderId={}: no longer PENDING/IN_PROGRESS", order.getId()); + return null; + } + DiagnosticOrder fresh = current.get(); + fresh.setStatus(DiagnosticOrderStatus.CLOSED.name()); + fresh.setReasonToClose(reason); + fresh.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + fresh.setModifiedBy("SYSTEM"); + fresh.setProcessed("N"); + diagnosticOrderRepo.save(fresh); + notifyProviderOrderClosed(fresh, reason); + return null; + }); + } + + private static boolean sameResult(DiagnosticResult stored, DiagnosticPollResult polled) { + return java.util.Objects.equals(stored.getProviderStatus(), polled.getStatus() == null ? null : polled.getStatus().name()) + && java.util.Objects.equals(stored.getResultSummary(), polled.getResultSummary()) + && java.util.Objects.equals(stored.getTbPresence(), polled.getTbPresence()) + && java.util.Objects.equals(stored.getTbConfidence(), polled.getTbConfidence()) + && java.util.Objects.equals(stored.getDrugResistancePresence(), polled.getDrugResistancePresence()) + && (polled.getAssets() == null || polled.getAssets().isEmpty()); + } + + private static boolean sameOrderStatus(DiagnosticOrder order, DiagnosticPollResult polled) { + return polled.getStatus() != null + && polled.getStatus().name().equals(order.getStatus()) + && java.util.Objects.equals(order.getErrorMessage(), polled.getErrorMessage()) + && (polled.getProviderOrderId() == null + || polled.getProviderOrderId().equals(order.getProviderOrderId())); + } + + private Optional findOutstandingOrder(Long orderId) { + return diagnosticOrderRepo.findById(orderId) + .filter(o -> !Boolean.TRUE.equals(o.getDeleted())) + .filter(o -> OUTSTANDING_STATUSES.contains(o.getStatus())); + } + + private DiagnosticPollResult pollOnceLocked(DiagnosticOrder order) throws Exception { // A null pushResponseJson means the order was never actually confirmed as pushed to the // vendor (e.g. a stale/legacy row) — polling the vendor for it would be meaningless, so // fail it outright instead of burning a vendor call every tick. @@ -338,15 +677,24 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { order.setStatus(DiagnosticOrderStatus.FAILED.name()); order.setErrorMessage("No push response recorded for this order — cannot poll"); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setModifiedBy("SYSTEM"); + order.setProcessed("N"); diagnosticOrderRepo.save(order); return null; } DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); DiagnosticPollResult result = provider.pollResult(order, false); if (DiagnosticOrderStatus.COMPLETED.equals(result.getStatus())) { - // Save as COMPLETED now, without assets, so a failure fetching/ingesting assets below - // doesn't also lose the already-confirmed completed status (see pollSingle's catch). - processResult(order, result); + // Save as COMPLETED (or CLOSED for an invalid outcome) now, without assets, so a failure + // fetching/ingesting assets below doesn't also lose the already-confirmed status (see + // pollSingle's catch). + boolean invalid = closeIfInvalidPolledResult(order, result); + processResult(order, result, invalid, "SYSTEM"); + if (invalid) { + // Before the asset fetch below: if that throws, this CLOSED order is never polled + // again, so the retest must already exist by then. + pushRetestOrder(order); + } result = provider.pollResult(order, true); // Only ingest the documents this time — the result/order fields were already saved above // and this second, asset-bearing response carries the same status/summary, so re-saving @@ -373,29 +721,51 @@ private DiagnosticOrder resolveOrder(Long beneficiaryId, String orderType, Long return findLatestOrder(beneficiaryId, orderType); } DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); - return diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, type.name()) + return diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, type.name()) .orElseThrow(() -> new Exception("DiagnosticOrder not found for beneficiaryId=" + beneficiaryId + ", visitCode=" + visitCode + ", orderType=" + orderType)); } @Override public DiagnosticOrderResultDto triggerManualPoll(Long beneficiaryId, String orderType, Long visitCode) throws Exception { - DiagnosticOrder order = resolveOrder(beneficiaryId, orderType, visitCode); - DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); - DiagnosticPollResult pollResult = provider.pollResult(order, true); - return processResult(order, pollResult); + // Locked, and the order read inside the lock, so this can't interleave with a manual entry or a + // scheduler poll for the same beneficiary and save over what they just wrote. + return withBeneficiaryLock(beneficiaryId, () -> { + DiagnosticOrder order = resolveOrder(beneficiaryId, orderType, visitCode); + if (DiagnosticOrderStatus.MANUAL_ENTRY.name().equals(order.getStatus())) { + throw new IllegalStateException("Cannot poll a MANUAL_ENTRY order — no vendor is involved, " + + "submit the result via manualResult instead"); + } + if (order.getManuallyEnteredBy() != null) { + throw new IllegalStateException("Cannot poll this order: its result was entered manually by " + + order.getManuallyEnteredBy() + " and a vendor poll would overwrite it"); + } + DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); + DiagnosticPollResult pollResult = provider.pollResult(order, true); + boolean invalid = closeIfInvalidPolledResult(order, pollResult); + return processResult(order, pollResult, invalid, "SYSTEM"); + }); } @Override public DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visitCode) throws Exception { + // Locked so the terminal-status check and the reset to PENDING can't straddle a manual entry + // that completes the order in between (which would reopen it for polling). + return withBeneficiaryLock(beneficiaryId, () -> retryPollLocked(beneficiaryId, orderType, visitCode)); + } + + private DiagnosticOrder retryPollLocked(Long beneficiaryId, String orderType, Long visitCode) throws Exception { DiagnosticOrder order = resolveOrder(beneficiaryId, orderType, visitCode); String status = order.getStatus(); if (DiagnosticOrderStatus.COMPLETED.name().equals(status) - || DiagnosticOrderStatus.CANCELLED.name().equals(status) - || DiagnosticOrderStatus.REFUSED.name().equals(status) - || DiagnosticOrderStatus.MANUAL_ENTRY.name().equals(status)) { - throw new IllegalStateException("Cannot retry polling for order in terminal status " + status); + || DiagnosticOrderStatus.CLOSED.name().equals(status)) { + throw new IllegalStateException("Cannot retry polling for order in terminal status " + status + + " — create a new order instead"); + } + if (DiagnosticOrderStatus.MANUAL_ENTRY.name().equals(status)) { + throw new IllegalStateException("Cannot retry polling for a MANUAL_ENTRY order — no vendor is involved, " + + "submit the result via manualResult instead"); } // retriedAt is kept as an audit timestamp only — the scheduler no longer uses it to anchor a @@ -404,6 +774,7 @@ public DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visi order.setRetriedAt(new Timestamp(System.currentTimeMillis())); order.setStatus(DiagnosticOrderStatus.PENDING.name()); order.setErrorMessage(null); + order.setProcessed("N"); return diagnosticOrderRepo.save(order); } @@ -419,22 +790,18 @@ public List getOrdersByBeneficiaryId(Long beneficiaryId) throws @Override public DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderType, Long visitCode) { - DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); - dto.setOrderType(orderType); - Optional orderOpt = visitCode != null - ? diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType) + ? diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType) : diagnosticOrderRepo.findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType); if (orderOpt.isEmpty()) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setOrderType(orderType); dto.setStatus("NOT_FOUND"); return dto; } DiagnosticOrder order = orderOpt.get(); - dto.setExternalOrderId(order.getExternalOrderId()); - dto.setStatus(order.getStatus()); - dto.setErrorMessage(order.getErrorMessage()); - dto.setReasonForRefusal(order.getReasonToClose()); + DiagnosticOrderResultDto dto = toResultDto(order); diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()).ifPresent(result -> { dto.setProviderStatus(result.getProviderStatus()); @@ -446,24 +813,75 @@ public DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderT return dto; } + @Override + public java.util.Map> getOrderRecordsByIds(java.util.Collection ids) { + java.util.Map> out = new java.util.LinkedHashMap<>(); + for (DiagnosticOrder order : diagnosticOrderRepo.findAllById(ids)) { + if (Boolean.TRUE.equals(order.getDeleted())) continue; + DiagnosticOrderResultDto dto = toResultDto(order); + if (order.getExternalOrderId() != null) { + diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()).ifPresent(result -> { + dto.setProviderStatus(result.getProviderStatus()); + dto.setResultSummary(result.getResultSummary()); + dto.setTbPresence(result.getTbPresence()); + dto.setTbConfidence(result.getTbConfidence()); + dto.setDrugResistancePresence(result.getDrugResistancePresence()); + }); + } + java.util.Map m = new java.util.LinkedHashMap<>(); + m.put("id", order.getId()); + m.put("beneficiaryId", order.getBeneficiaryId()); + m.put("visitCode", order.getVisitCode()); + m.put("createdDate", order.getCreatedDate()); + m.put("externalOrderId", dto.getExternalOrderId()); + m.put("orderType", dto.getOrderType()); + m.put("status", dto.getStatus()); + m.put("providerStatus", dto.getProviderStatus()); + m.put("resultSummary", dto.getResultSummary()); + m.put("errorMessage", dto.getErrorMessage()); + m.put("reasonToClose", dto.getReasonToClose()); + m.put("tbPresence", dto.getTbPresence()); + m.put("tbConfidence", dto.getTbConfidence()); + m.put("drugResistancePresence", dto.getDrugResistancePresence()); + out.put(order.getId(), m); + } + return out; + } + + private DiagnosticOrderResultDto toResultDto(DiagnosticOrder order) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setExternalOrderId(order.getExternalOrderId()); + dto.setOrderType(order.getOrderType()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + dto.setReasonToClose(order.getReasonToClose()); + return dto; + } + @Override public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, - Integer providerServiceMapId) { + Integer providerServiceMapId) { DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); - List awaitingProviderResult = diagnosticOrderRepo - .findBeneficiaryIdsAwaitingProviderResult(type.name(), villageId, providerServiceMapId); - List completed = diagnosticOrderRepo - .findBeneficiaryIdsCompleted(type.name(), villageId, providerServiceMapId); - List pollingTimedOut = diagnosticOrderRepo - .findBeneficiaryIdsPollingTimedOut(type.name(), villageId, providerServiceMapId); - List failed = diagnosticOrderRepo - .findBeneficiaryIdsFailed(type.name(), villageId, providerServiceMapId); - List refused = diagnosticOrderRepo - .findBeneficiaryIdsRefused(type.name(), villageId, providerServiceMapId); - List awaitingManualEntry = diagnosticOrderRepo - .findBeneficiaryIdsAwaitingManualEntry(type.name(), villageId, providerServiceMapId); - return new DiagnosticOrderStatusSummaryDto(awaitingProviderResult, completed, pollingTimedOut, failed, refused, - awaitingManualEntry); + List awaitingProviderResult = new ArrayList<>(); + List completed = new ArrayList<>(); + List failed = new ArrayList<>(); + List closed = new ArrayList<>(); + List awaitingManualEntry = new ArrayList<>(); + // One query instead of one per bucket; buckets are the same status groups the per-bucket + // queries used (matched case-insensitively, like the database comparison was). + for (Object[] row : diagnosticOrderRepo.findLatestOrderStatusByBeneficiary(type.name(), villageId, providerServiceMapId)) { + Long beneficiaryId = row[0] != null ? ((Number) row[0]).longValue() : null; + String status = row[1] != null ? row[1].toString().toUpperCase(java.util.Locale.ROOT) : ""; + switch (status) { + case "PENDING", "IN_PROGRESS" -> awaitingProviderResult.add(beneficiaryId); + case "COMPLETED" -> completed.add(beneficiaryId); + case "FAILED" -> failed.add(beneficiaryId); + case "CLOSED", "CANCELLED", "EXPIRED", "REFUSED" -> closed.add(beneficiaryId); + case "MANUAL_ENTRY" -> awaitingManualEntry.add(beneficiaryId); + default -> { } + } + } + return new DiagnosticOrderStatusSummaryDto(awaitingProviderResult, completed, failed, closed, awaitingManualEntry); } @Override @@ -484,14 +902,128 @@ public VendorHealthDto checkVendorHealth(String orderType) throws Exception { } @Override - public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request) throws Exception { + public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request, String jwtToken) + throws Exception { + boolean hasResult = request.getResultSummary() != null && !request.getResultSummary().isBlank(); + boolean hasReasonToClose = request.getReasonToClose() != null && !request.getReasonToClose().isBlank(); + if (hasResult == hasReasonToClose) { + throw new IllegalArgumentException("Exactly one of resultSummary or reasonToClose must be provided"); + } + + String actingUserName = resolveActingUserName(jwtToken); + + if (hasReasonToClose) { + DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); + DiagnosticOrder closed = closeOrderManually(request.getBeneficiaryId(), orderType, + request.getReasonToClose(), actingUserName); + return toResultDto(closed); + } + + return withBeneficiaryLock(request.getBeneficiaryId(), + () -> submitManualResultLocked(request, actingUserName)); + } + + private DiagnosticOrderResultDto submitManualResultLocked(ManualDiagnosticResultRequestDto request, + String actingUserName) throws Exception { DiagnosticOrder order = findLatestOrder(request.getBeneficiaryId(), request.getOrderType()); if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { throw new IllegalStateException("Cannot submit manual result: order is already COMPLETED (beneficiaryId=" + request.getBeneficiaryId() + ", orderType=" + request.getOrderType() + ")"); } + order.setManuallyEnteredBy(actingUserName); + // An invalid test outcome is still recorded exactly like a normal result (result row + + // tb_suspected write-back), but the order is CLOSED rather than COMPLETED. + DiagnosticOrderStatus status = isInvalidResult(order.getOrderType(), request.getResultSummary(), + SPUTUM_INVALID_MANUAL_RESULT) + ? DiagnosticOrderStatus.CLOSED + : DiagnosticOrderStatus.COMPLETED; DiagnosticPollResult pollResult = new DiagnosticPollResult( - DiagnosticOrderStatus.COMPLETED, null, request.getResultSummary(), null, null, null, null, null, null); - return processResult(order, pollResult); + status, null, request.getResultSummary(), null, null, null, null, null, null); + return processResult(order, pollResult, true, actingUserName); + } + + // sputumInvalidResult differs by source: SPUTUM_INVALID_POLLED_RESULT for vendor polls, + // SPUTUM_INVALID_MANUAL_RESULT for manual entry. The X-ray value is the same for both. + private static boolean isInvalidResult(String orderTypeCode, String resultSummary, String sputumInvalidResult) { + if (resultSummary == null) { + return false; + } + String summary = resultSummary.trim(); + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderTypeCode); + if (type == DiagnosticOrderType.XRAY_CHEST) { + return XRAY_INVALID_RESULT.equalsIgnoreCase(summary); + } + if (type == DiagnosticOrderType.MTB || type == DiagnosticOrderType.MDR_RIF) { + return sputumInvalidResult.equalsIgnoreCase(summary); + } + return false; + } + + // Automatic retest after a polled invalid outcome: a brand new PENDING row copying the closed + // order's beneficiary, visit, vendor and patient details, with only a fresh externalOrderId, + // pushed to the vendor straight away. Never throws — a failure here must not undo the close. + private void pushRetestOrder(DiagnosticOrder closed) { + try { + withBeneficiaryLockBlocking(closed.getBeneficiaryId(), () -> { + pushRetestOrderLocked(closed); + return null; + }); + } catch (Exception e) { + logger.error("Failed to create retest order after invalid result, closedOrderId={}: {}", + closed.getId(), e.getMessage()); + } + } + + private void pushRetestOrderLocked(DiagnosticOrder closed) { + // A user push may have created a fresh order for this beneficiary+orderType after this one was + // closed — the retest would then put the same patient on the vendor twice, so skip it. + Optional latest = diagnosticOrderRepo + .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + closed.getBeneficiaryId(), closed.getOrderType()); + if (latest.isPresent() && !latest.get().getId().equals(closed.getId()) + && BLOCKING_STATUSES.contains(latest.get().getStatus())) { + logger.info("Retest skipped for closedOrderId={}: newer order id={} (status={}) already exists", + closed.getId(), latest.get().getId(), latest.get().getStatus()); + return; + } + try { + DiagnosticOrder retest = new DiagnosticOrder(); + retest.setVanID(closed.getVanID()); + retest.setParkingPlaceID(closed.getParkingPlaceID()); + retest.setOrderEvent(closed.getOrderEvent()); + retest.setBeneficiaryId(closed.getBeneficiaryId()); + retest.setVisitCode(closed.getVisitCode()); + retest.setProviderServiceName(closed.getProviderServiceName()); + retest.setProviderCode(closed.getProviderCode()); + retest.setOrderType(closed.getOrderType()); + retest.setExternalOrderId(String.format("%s-%d-%s", UUID.randomUUID(), closed.getVisitCode(), + closed.getOrderType())); + retest.setStatus(DiagnosticOrderStatus.PENDING.name()); + retest.setPatientFirstName(closed.getPatientFirstName()); + retest.setPatientLastName(closed.getPatientLastName()); + retest.setPatientDateOfBirth(closed.getPatientDateOfBirth()); + retest.setPatientSex(closed.getPatientSex()); + retest.setCreatedBy("SYSTEM"); + retest.setModifiedBy("SYSTEM"); + retest = diagnosticOrderRepo.save(retest); + retest = pushToProvider(retest, closed.getProviderCode()); + logger.info("Retest order created after invalid result: closedOrderId={}, retestOrderId={}, " + + "externalOrderId={}, status={}", closed.getId(), retest.getId(), retest.getExternalOrderId(), + retest.getStatus()); + } catch (Exception e) { + logger.error("Failed to create retest order after invalid result, closedOrderId={}: {}", + closed.getId(), e.getMessage()); + } + } + + // A vendor poll that COMPLETED with an invalid outcome is downgraded to CLOSED in place. + // Returns true when that happened, so the caller still writes the result back to tb_suspected. + private static boolean closeIfInvalidPolledResult(DiagnosticOrder order, DiagnosticPollResult result) { + if (DiagnosticOrderStatus.COMPLETED.equals(result.getStatus()) + && isInvalidResult(order.getOrderType(), result.getResultSummary(), SPUTUM_INVALID_POLLED_RESULT)) { + result.setStatus(DiagnosticOrderStatus.CLOSED); + return true; + } + return false; } } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java index b21c7095c..1a36e8cd0 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -2,7 +2,6 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.integration.provider.DiagnosticProviderFactory; -import com.iemr.flw.masterEnum.DiagnosticOrderStatus; import com.iemr.flw.masterEnum.DiagnosticOrderType; import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.service.DiagnosticOrderService; @@ -11,16 +10,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.web.client.ResourceAccessException; import java.sql.Timestamp; +import java.time.LocalDate; import java.util.List; +import java.util.function.Supplier; -// Every due order (status PENDING/IN_PROGRESS) is polled on every tick from the moment it's created — -// there is no per-order initial delay or rolling give-up window anymore. A tick is skipped entirely -// for an order-type family if diagnostic.provider.xray / diagnostic.provider.truenat has no active -// vendor configured right now (checked live via DiagnosticProviderFactory, not any value stored on the -// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING/IN_PROGRESS (for -// order-type families that do have an active vendor) and marks it EXPIRED. @Service public class DiagnosticPollSchedulerService { @@ -37,50 +33,70 @@ public class DiagnosticPollSchedulerService { @Scheduled(fixedDelayString = "${diagnostic.poll.xray.tick-ms:15000}") public void pollXrayOrders() { - if (noActiveVendor(DiagnosticOrderType.XRAY_CHEST)) { - logger.debug("No active vendor configured for XRAY_CHEST — skipping poll tick"); - return; - } - List candidates = diagnosticOrderRepo.findXrayDueForPoll(); - logger.info("XRAY poll tick: {} order(s) in due-for-poll queue", candidates.size()); - if (candidates.isEmpty()) return; - - for (DiagnosticOrder order : candidates) { - pollSingle(order); - } - logger.info("Polled {} pending XRAY diagnostic orders", candidates.size()); + pollDueOrders("XRAY", DiagnosticOrderType.XRAY_CHEST, + diagnosticOrderRepo::findXrayDueForPoll); } @Scheduled(fixedDelayString = "${diagnostic.poll.truenat.tick-ms:60000}") public void pollTrueNatOrders() { - if (noActiveVendor(DiagnosticOrderType.MTB)) { - logger.debug("No active vendor configured for TrueNat — skipping poll tick"); + pollDueOrders("TrueNat", DiagnosticOrderType.MTB, + diagnosticOrderRepo::findTrueNatDueForPoll); + } + + private void pollDueOrders(String label, DiagnosticOrderType orderType, Supplier> dueOrders) { + String providerCode = providerFactory.getProviderCodeForOrderType(orderType); + if (providerCode == null || providerCode.isBlank()) { + logger.debug("No active vendor configured for {} — skipping poll tick", label); return; } - List candidates = diagnosticOrderRepo.findTrueNatDueForPoll(); - logger.info("TrueNat poll tick: {} order(s) in due-for-poll queue", candidates.size()); + List candidates = dueOrders.get(); + logger.info("{} poll tick: {} order(s) in due-for-poll queue", label, candidates.size()); if (candidates.isEmpty()) return; - for (DiagnosticOrder order : candidates) { - pollSingle(order); + if (!providerFactory.getProvider(providerCode).checkHealth(orderType)) { + logger.warn("{} vendor ping failed — skipping poll tick, {} order(s) left for the next tick", + label, candidates.size()); + return; } - logger.info("Polled {} pending TrueNat diagnostic orders", candidates.size()); - } - @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 0 18 * * *}") - public void expireOutstandingOrders() { - List outstanding = diagnosticOrderRepo.findAllOutstandingOrders(); - int expired = 0; - for (DiagnosticOrder order : outstanding) { - if (noActiveVendor(DiagnosticOrderType.fromCode(order.getOrderType()))) { - continue; // awaiting manual entry — not subject to the polling cutoff + int polled = 0; + for (DiagnosticOrder order : candidates) { + polled++; + if (!pollSingle(order)) { + logger.warn("{} vendor unreachable mid-tick — stopping, {} order(s) left for the next tick", + label, candidates.size() - polled); + break; } - expire(order); - expired++; - } - if (expired > 0) { - logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) EXPIRED", expired); } + logger.info("Polled {} of {} pending {} diagnostic orders", polled, candidates.size(), label); + } + + // Auto-closure of outstanding orders at the daily cutoff is disabled for now — orders are no longer + // closed automatically. Uncomment this job (and expire() below) to re-enable it. + // @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 50 23 * * *}") + // public void expireOutstandingOrders() { + // List outstanding = diagnosticOrderRepo.findAllOutstandingOrders(startOfToday(), startOfTomorrow()); + // int expired = 0; + // for (DiagnosticOrder order : outstanding) { + // if (noActiveVendor(DiagnosticOrderType.fromCode(order.getOrderType()))) { + // continue; // awaiting manual entry — not subject to the polling cutoff + // } + // expire(order); + // expired++; + // } + // if (expired > 0) { + // logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) CLOSED", expired); + // } + // } + + // The (disabled) daily close only handles orders created today. JVM default zone on purpose: it's the + // same clock created_date is stamped with (@CreationTimestamp), so the day boundaries line up. + private static Timestamp startOfToday() { + return Timestamp.valueOf(LocalDate.now().atStartOfDay()); + } + + private static Timestamp startOfTomorrow() { + return Timestamp.valueOf(LocalDate.now().plusDays(1).atStartOfDay()); } private boolean noActiveVendor(DiagnosticOrderType orderType) { @@ -88,24 +104,31 @@ private boolean noActiveVendor(DiagnosticOrderType orderType) { return providerCode == null || providerCode.isBlank(); } - private void expire(DiagnosticOrder order) { - order.setStatus(DiagnosticOrderStatus.EXPIRED.name()); - order.setErrorMessage("Daily polling cutoff reached without a result"); - order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); - diagnosticOrderRepo.save(order); - } + // Used only by the (disabled) auto-closure job above. expireOrder re-reads the order under the + // beneficiary lock, so an order completed manually after this batch was loaded is left alone. + // private void expire(DiagnosticOrder order) { + // String reason = "Order validity expired for the day " + LocalDate.now(ZoneId.of("Asia/Kolkata")); + // try { + // diagnosticOrderService.expireOrder(order, reason); + // } catch (Exception e) { + // logger.error("Expiry failed for orderId={}: {}", order.getId(), e.getMessage()); + // } + // } - private void pollSingle(DiagnosticOrder order) { + // Returns false only when the vendor couldn't be reached at all (connect/read timeout, refused + // connection) — the caller stops the tick then. Any other failure is specific to this order + // (bad data, vendor-side rejection), so it's recorded and the tick carries on. + private boolean pollSingle(DiagnosticOrder order) { try { - // pollOnce now saves the result/order fields and ingests any documents itself — - // nothing left to do here on success. diagnosticOrderService.pollOnce(order); + return true; + } catch (ResourceAccessException e) { + logger.error("Vendor unreachable polling orderId={}: {}", order.getId(), e.getMessage()); + return false; } 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); + return true; } } + } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java index c40b65d7b..1df9a2018 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java @@ -30,6 +30,7 @@ import com.iemr.flw.domain.iemr.SectionQuestion; import com.iemr.flw.dto.iemr.DynamicFormDTO; import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.LatestFormVersionDTO; import com.iemr.flw.dto.iemr.OptionConditionDTO; import com.iemr.flw.dto.iemr.QuestionOptionDTO; import com.iemr.flw.dto.iemr.QuestionValidationDTO; @@ -150,7 +151,9 @@ public List getAllForms() { .stream() .map(form -> { try { - return loadLatestFromDb(form.getFormId()); + DynamicFormDTO latest = loadLatestFromDb(form.getFormId()); + latest.setVersions(loadAllVersionsFromDb(form.getFormId())); + return latest; } catch (RuntimeException e) { log.warn("Skipping form {} — no active version: {}", form.getFormId(), e.getMessage()); return null; @@ -211,6 +214,10 @@ private void buildSectionsFromDto(List dtos, FormVersion version for (QuestionOptionDTO oDto : qDto.getOptions()) { QuestionOption opt = mapper.toEntity(oDto); opt.setSectionQuestion(savedQ); + if (opt.getOptionUuid() == null || opt.getOptionUuid().isBlank()) { + opt.setOptionUuid(QuestionOption.buildOptionUuid( + version.getDynamicForm().getFormType(), opt.getOptionValue())); + } opt.setConditions(new ArrayList<>()); QuestionOption savedOpt = optionRepo.save(opt); if (oDto.getConditions() != null && !oDto.getConditions().isEmpty()) { @@ -266,6 +273,14 @@ private QuestionValidation buildValidationEntity(QuestionValidationDTO dto, // ── LOAD / READ HELPERS ─────────────────────────────────────────────────── + /** Full definition of every active version of the form, oldest first. */ + private List loadAllVersionsFromDb(Long formId) { + return versionRepo.findByDynamicForm_FormIdOrderByVersionNumberAsc(formId).stream() + .filter(version -> Boolean.TRUE.equals(version.getIsActive())) + .map(version -> buildFormDto(version.getDynamicForm(), version)) + .collect(Collectors.toList()); + } + private DynamicFormDTO loadLatestFromDb(Long formId) { FormVersion version = versionRepo.findByDynamicForm_FormIdAndIsLatest(formId, true) .orElseThrow(() -> new RuntimeException("No active version for formId: " + formId)); @@ -280,7 +295,7 @@ private DynamicFormDTO loadLatestFromDb(Long formId) { private DynamicFormDTO buildFormDto(DynamicForm form, FormVersion version) { // Query 1: sections List sections = sectionRepo - .findByFormVersion_VersionIdOrderByDisplayOrderAsc(version.getVersionId()); + .findByFormVersion_VersionIdAndIsActiveTrueOrderByDisplayOrderAsc(version.getVersionId()); if (sections.isEmpty()) { DynamicFormDTO dto = mapper.toDto(form); dto.setVersionNumber(version.getVersionNumber()); @@ -413,4 +428,9 @@ private SectionQuestion requireQuestion(Long questionId) { .orElseThrow(() -> new RuntimeException("Question not found: " + questionId)); } + @Override + @Transactional(readOnly = true) + public List getLatestFormVersions() { + return versionRepo.findLatestVersionOfActiveForms(); + } } diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java new file mode 100644 index 000000000..235093d4f --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java @@ -0,0 +1,329 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.OptionCondition; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.mapper.DynamicFormMapper; +import com.iemr.flw.repo.iemr.DynamicFormRepo; +import com.iemr.flw.repo.iemr.FormSectionRepo; +import com.iemr.flw.repo.iemr.FormVersionRepo; +import com.iemr.flw.repo.iemr.OptionConditionRepo; +import com.iemr.flw.repo.iemr.QuestionOptionRepo; +import com.iemr.flw.repo.iemr.QuestionValidationRepo; +import com.iemr.flw.repo.iemr.SectionQuestionRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.IntConsumer; +import java.util.function.Supplier; + +/** + * In-place reconciliation for dynamic form structure — see {@link DynamicFormReconciliationService}. + * Standalone: does not depend on {@link DynamicFormDefinitionServiceImpl}'s private helpers, since + * that service's write path (full version clone) is a deliberately different mechanism from this one. + * + * @author Piramal Swasthya + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class DynamicFormReconciliationServiceImpl implements DynamicFormReconciliationService { + + private final DynamicFormRepo formRepo; + private final FormVersionRepo versionRepo; + private final FormSectionRepo sectionRepo; + private final SectionQuestionRepo questionRepo; + private final QuestionOptionRepo optionRepo; + private final QuestionValidationRepo validationRepo; + private final OptionConditionRepo conditionRepo; + private final DynamicFormMapper mapper; + + // ── Form / Version ──────────────────────────────────────────────────────── + + @Override + @Transactional + public DynamicForm ensureForm(DynamicFormDTO dto) { + Optional existing = formRepo.findByFormUuid(dto.getFormUuid()); + if (existing.isPresent()) { + DynamicForm form = existing.get(); + if (Boolean.FALSE.equals(form.getIsActive())) { + form.setIsActive(true); + form = formRepo.save(form); + log.info("Form '{}' was inactive — reactivated.", dto.getFormUuid()); + } + return form; + } + DynamicForm form = mapper.toEntity(dto); + form.setFormId(null); + return formRepo.save(form); + } + + @Override + @Transactional + public FormVersion ensureLatestVersion(DynamicForm form) { + return versionRepo.findByDynamicForm_FormIdAndIsLatest(form.getFormId(), true) + .orElseGet(() -> { + FormVersion version = new FormVersion(); + version.setDynamicForm(form); + version.setVersionNumber(1); + version.setIsLatest(true); + return versionRepo.save(version); + }); + } + + // ── Section / Question / Option / Validation / Condition ───────────────── + + @Override + @Transactional + public FormSection ensureSection(FormVersion version, FormSectionDTO dto) { + return sectionRepo.findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), dto.getSectionUuid()) + .orElseGet(() -> createSection(version, dto)); + } + + @Override + @Transactional + public SectionQuestion ensureQuestion(FormSection section, SectionQuestionDTO dto) { + return questionRepo.findByFormSection_SectionIdAndQuestionUuid(section.getSectionId(), dto.getQuestionUuid()) + .orElseGet(() -> createQuestion(section, dto)); + } + + @Override + @Transactional + public QuestionOption ensureOption(SectionQuestion question, QuestionOptionDTO dto) { + return optionRepo.findBySectionQuestion_QuestionIdAndOptionValue(question.getQuestionId(), dto.getOptionValue()) + .orElseGet(() -> createOption(question, dto)); + } + + @Override + @Transactional + public QuestionValidation ensureValidation(SectionQuestion question, QuestionValidationDTO dto) { + return validationRepo.findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + question.getQuestionId(), dto.getValidationType(), dto.getValidationParam()) + .orElseGet(() -> createValidation(question, dto)); + } + + @Override + @Transactional + public OptionCondition ensureCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version) { + Optional existing; + if (dto.getTargetQuestionUuid() != null) { + existing = conditionRepo.findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + option.getOptionId(), dto.getActionType(), dto.getTargetQuestionUuid()); + } else if (dto.getTargetSectionUuid() != null) { + existing = conditionRepo.findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + option.getOptionId(), dto.getActionType(), dto.getTargetSectionUuid()); + } else { + existing = Optional.empty(); + } + return existing.orElseGet(() -> createCondition(option, dto, version)); + } + + // ── Update / Remove ──────────────────────────────────────────────────────── + + @Override + @Transactional + public SectionQuestion updateQuestion(Long questionId, SectionQuestionDTO dto) { + SectionQuestion question = questionRepo.findById(questionId) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + questionId)); + question.setQuestionText(dto.getQuestionText()); + question.setQuestionTextHindi(dto.getQuestionTextHindi()); + question.setQuestionType(dto.getQuestionType()); + question.setIsMandatory(dto.getIsMandatory()); + question.setMaxLength(dto.getMaxLength()); + question.setDefaultValue(dto.getDefaultValue()); + question.setContainsPii(dto.getContainsPii()); + if (dto.getDisplayOrder() != null && !dto.getDisplayOrder().equals(question.getDisplayOrder())) { + moveQuestion(question, dto.getDisplayOrder()); + } + return questionRepo.save(question); + } + + @Override + @Transactional + public void unlinkQuestion(Long questionId) { + SectionQuestion question = questionRepo.findById(questionId) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + questionId)); + question.setIsActive(false); + questionRepo.save(question); + } + + @Override + @Transactional + public void removeOption(Long optionId) { + QuestionOption option = optionRepo.findById(optionId) + .orElseThrow(() -> new NoSuchElementException("Option not found: " + optionId)); + option.setIsActive(false); + optionRepo.save(option); + } + + // ── Read-only lookups ────────────────────────────────────────────────────── + + @Override + public Optional latestVersion(String formUuid) { + return formRepo.findByFormUuid(formUuid) + .flatMap(form -> versionRepo.findByDynamicForm_FormIdAndIsLatest(form.getFormId(), true)); + } + + @Override + public Optional section(FormVersion version, String sectionUuid) { + return sectionRepo.findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), sectionUuid); + } + + @Override + public Optional question(FormSection section, String questionUuid) { + return questionRepo.findByFormSection_SectionIdAndQuestionUuid(section.getSectionId(), questionUuid); + } + + @Override + public Optional option(SectionQuestion question, String optionValue) { + return optionRepo.findBySectionQuestion_QuestionIdAndOptionValue(question.getQuestionId(), optionValue); + } + + // ── Create helpers ───────────────────────────────────────────────────────── + + private FormSection createSection(FormVersion version, FormSectionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> sectionRepo.findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(version.getVersionId()) + .map(FormSection::getDisplayOrder), + from -> sectionRepo.shiftDisplayOrder(version.getVersionId(), from, Integer.MAX_VALUE, 1)); + FormSection section = mapper.toEntity(dto); + section.setSectionId(null); + section.setFormVersion(version); + section.setDisplayOrder(order); + section.setQuestions(new ArrayList<>()); + return sectionRepo.save(section); + } + + private SectionQuestion createQuestion(FormSection section, SectionQuestionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> questionRepo.findTopByFormSection_SectionIdOrderByDisplayOrderDesc(section.getSectionId()) + .map(SectionQuestion::getDisplayOrder), + from -> questionRepo.shiftDisplayOrder(section.getSectionId(), from, Integer.MAX_VALUE, 1)); + SectionQuestion question = mapper.toEntity(dto); + question.setQuestionId(null); + question.setFormSection(section); + question.setDisplayOrder(order); + question.setOptions(new ArrayList<>()); + question.setValidations(new ArrayList<>()); + return questionRepo.save(question); + } + + private QuestionOption createOption(SectionQuestion question, QuestionOptionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> optionRepo.findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(question.getQuestionId()) + .map(QuestionOption::getDisplayOrder), + from -> optionRepo.shiftDisplayOrder(question.getQuestionId(), from, Integer.MAX_VALUE, 1)); + QuestionOption option = mapper.toEntity(dto); + option.setOptionId(null); + option.setSectionQuestion(question); + if (option.getOptionUuid() == null || option.getOptionUuid().isBlank()) { + option.setOptionUuid(QuestionOption.buildOptionUuid( + question.getFormSection().getFormVersion().getDynamicForm().getFormType(), + option.getOptionValue())); + } + option.setDisplayOrder(order); + option.setConditions(new ArrayList<>()); + return optionRepo.save(option); + } + + private QuestionValidation createValidation(SectionQuestion question, QuestionValidationDTO dto) { + QuestionValidation validation = mapper.toEntity(dto); + validation.setValidationId(null); + validation.setSectionQuestion(question); + return validationRepo.save(validation); + } + + private OptionCondition createCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version) { + OptionCondition condition = mapper.toEntity(dto); + condition.setConditionId(null); + condition.setQuestionOption(option); + + if (dto.getTargetQuestionUuid() != null) { + condition.setTargetQuestion(questionRepo + .findByFormSection_FormVersion_VersionIdAndQuestionUuid(version.getVersionId(), dto.getTargetQuestionUuid()) + .orElseThrow(() -> new NoSuchElementException( + "targetQuestionUuid not found in this form version: " + dto.getTargetQuestionUuid()))); + } else if (dto.getTargetQuestionId() != null) { + condition.setTargetQuestion(questionRepo.findById(dto.getTargetQuestionId()) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + dto.getTargetQuestionId()))); + } + + if (dto.getTargetSectionUuid() != null) { + condition.setTargetSection(sectionRepo + .findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), dto.getTargetSectionUuid()) + .orElseThrow(() -> new NoSuchElementException( + "targetSectionUuid not found in this form version: " + dto.getTargetSectionUuid()))); + } else if (dto.getTargetSectionId() != null) { + condition.setTargetSection(sectionRepo.findById(dto.getTargetSectionId()) + .orElseThrow(() -> new NoSuchElementException("Section not found: " + dto.getTargetSectionId()))); + } + + return conditionRepo.save(condition); + } + + /** + * Omitted displayOrder → append after the current max sibling (no shift needed). + * Explicit displayOrder → push every sibling at or after that position up by one, then use it as-is. + */ + private int resolveOrder(Integer requested, Supplier> currentMaxSupplier, + IntConsumer shiftFromInclusive) { + if (requested == null) { + return currentMaxSupplier.get().orElse(0) + 1; + } + shiftFromInclusive.accept(requested); + return requested; + } + + /** + * Moves a question already at oldPos to newPos within its section, shifting the siblings in + * between to keep displayOrder contiguous — same three-branch logic a full reorder needs + * regardless of direction. + */ + private void moveQuestion(SectionQuestion question, int newPos) { + int oldPos = question.getDisplayOrder(); + Long sectionId = question.getFormSection().getSectionId(); + if (newPos < oldPos) { + questionRepo.shiftDisplayOrder(sectionId, newPos, oldPos - 1, 1); + } else { + questionRepo.shiftDisplayOrder(sectionId, oldPos + 1, newPos, -1); + } + question.setDisplayOrder(newPos); + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index e163959ce..b14ab50c1 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -61,9 +61,12 @@ import java.sql.Timestamp; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -136,7 +139,7 @@ public FormResponseDTO completeForm(FormResponseRequest request, String jwtToken Timestamp now = new Timestamp(System.currentTimeMillis()); List existing = - formResponseRepo.findByBeneficiaryIdAndFormId(request.getBeneficiaryId(), formId); + formResponseRepo.findByBeneficiaryIdAndVersionId(request.getBeneficiaryId(), version.getVersionId()); FormResponse formResponse = existing.isEmpty() ? createFormResponse(request, version, FormResponseStatus.SUBMITTED.name(), now, vanID, parkingPlaceID) : existing.get(0); @@ -164,8 +167,21 @@ public List getResponsesByBeneficiary(Long beneficiaryId, Integ List responses = beneficiaryId != null ? formResponseRepo.findByBeneficiaryIdAndFormId(beneficiaryId, formId) : formResponseRepo.findByFormIdFiltered(formId, villageId, providerServiceMapId); + + // Responses may belong to older versions than the latest one resolved above. + Set versionIds = responses.stream() + .map(FormResponse::getVersionId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map versionNumberById = formVersionRepo.findAllById(versionIds).stream() + .collect(Collectors.toMap(FormVersion::getVersionId, FormVersion::getVersionNumber)); + return responses.stream() - .map(r -> buildFormResponseDTO(r, loadSectionResponseDTOs(r.getResponseId()))) + .map(r -> { + FormResponseDTO dto = buildFormResponseDTO(r, loadSectionResponseDTOs(r.getResponseId())); + dto.setVersionNumber(versionNumberById.get(r.getVersionId())); + return dto; + }) .collect(Collectors.toList()); } @@ -244,8 +260,12 @@ private FormResponseDTO processSections( Set questionIds = allQuestions.stream() .map(SectionQuestion::getQuestionId) .collect(Collectors.toSet()); + Map questionUuidById = allQuestions.stream() + .collect(Collectors.toMap(SectionQuestion::getQuestionId, SectionQuestion::getQuestionUuid)); List allOptions = questionOptionRepo.findByQuestionIdsOrderByDisplayOrderAsc(questionIds); + Map optionById = allOptions.stream() + .collect(Collectors.toMap(QuestionOption::getOptionId, Function.identity())); // Map: questionId → (optionValue → QuestionOption) Map> optionsByQuestion = allOptions.stream() @@ -299,7 +319,7 @@ private FormResponseDTO processSections( questionResponseRepo.updateVanSerialNo(qr.getQuestionResponseId(), qr.getQuestionResponseId()); } - sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses)); + sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses, questionUuidById, optionById)); } return buildFormResponseDTO(formResponse, sectionDTOs); @@ -442,6 +462,24 @@ private QuestionOption resolveOption( // ---- read helpers ---- + @Override + public Map getResponsesByIds(Collection responseIds) { + List responses = formResponseRepo.findAllById(responseIds); + Set versionIds = responses.stream() + .map(FormResponse::getVersionId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map versionNumberById = formVersionRepo.findAllById(versionIds).stream() + .collect(Collectors.toMap(FormVersion::getVersionId, FormVersion::getVersionNumber)); + Map out = new LinkedHashMap<>(); + for (FormResponse r : responses) { + FormResponseDTO dto = buildFormResponseDTO(r, loadSectionResponseDTOs(r.getResponseId())); + dto.setVersionNumber(versionNumberById.get(r.getVersionId())); + out.put(r.getResponseId(), dto); + } + return out; + } + private List loadSectionResponseDTOs(Long responseId) { List sections = sectionResponseRepo.findByResponseId(responseId); if (sections.isEmpty()) { @@ -461,11 +499,26 @@ private List loadSectionResponseDTOs(Long responseId) { Map sectionById = formSectionRepo.findAllById(sectionIds).stream() .collect(Collectors.toMap(FormSection::getSectionId, Function.identity())); + // findAllById includes unlinked (isActive=false) questions, so historical answers still resolve their uuid. + Set questionIds = allAnswers.stream() + .map(QuestionResponse::getQuestionId) + .collect(Collectors.toSet()); + Map questionUuidById = sectionQuestionRepo.findAllById(questionIds).stream() + .collect(Collectors.toMap(SectionQuestion::getQuestionId, SectionQuestion::getQuestionUuid)); + Set optionIds = allAnswers.stream() + .map(QuestionResponse::getOptionId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map optionById = questionOptionRepo.findAllById(optionIds).stream() + .collect(Collectors.toMap(QuestionOption::getOptionId, Function.identity())); + return sections.stream() .map(sr -> buildSectionResponseDTO( sr, sectionById.get(sr.getSectionId()), - answersBySectionResponse.getOrDefault(sr.getSectionResponseId(), List.of()))) + answersBySectionResponse.getOrDefault(sr.getSectionResponseId(), List.of()), + questionUuidById, + optionById)) .collect(Collectors.toList()); } @@ -489,12 +542,18 @@ private FormResponseDTO buildFormResponseDTO(FormResponse r, List answers) { + SectionResponse sr, FormSection section, List answers, + Map questionUuidById, Map optionById) { List answerDTOs = answers.stream() .map(a -> QuestionResponseDTO.builder() .questionResponseId(a.getQuestionResponseId()) .questionId(a.getQuestionId()) + .questionUuid(questionUuidById.get(a.getQuestionId())) .optionId(a.getOptionId()) + .optionValue(optionById.containsKey(a.getOptionId()) + ? optionById.get(a.getOptionId()).getOptionValue() : null) + .optionUuid(optionById.containsKey(a.getOptionId()) + ? optionById.get(a.getOptionId()).getOptionUuid() : null) .answerText(a.getAnswerText()) .build()) .collect(Collectors.toList()); diff --git a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java index f03398c5a..9a6e04877 100644 --- a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java +++ b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java @@ -112,7 +112,7 @@ public FormResponseDTO saveForBulk(FormResponseRequest req, String jwtToken) thr // Step 2: Check for an existing response for this beneficiary+form List existing = - formResponseRepo.findByBeneficiaryIdAndFormId(req.getBeneficiaryId(), formId); + formResponseRepo.findByBeneficiaryIdAndVersionId(req.getBeneficiaryId(), version.getVersionId()); FormResponse formResponse; if (!existing.isEmpty()) { @@ -221,8 +221,12 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR Set questionIds = allQuestions.stream() .map(SectionQuestion::getQuestionId) .collect(Collectors.toSet()); + Map questionUuidById = allQuestions.stream() + .collect(Collectors.toMap(SectionQuestion::getQuestionId, SectionQuestion::getQuestionUuid)); List allOptions = questionOptionRepo.findByQuestionIdsOrderByDisplayOrderAsc(questionIds); + Map optionById = allOptions.stream() + .collect(Collectors.toMap(QuestionOption::getOptionId, Function.identity())); Map> optionsByQuestion = allOptions.stream() .collect(Collectors.groupingBy( o -> o.getSectionQuestion().getQuestionId(), @@ -271,7 +275,7 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR for (QuestionResponse qr : questionResponses) { questionResponseRepo.updateVanSerialNo(qr.getQuestionResponseId(), qr.getQuestionResponseId()); } - sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses)); + sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses, questionUuidById, optionById)); } return buildFormResponseDTO(formResponse, sectionDTOs); @@ -425,12 +429,18 @@ private FormResponseDTO buildFormResponseDTO(FormResponse r, List answers) { + SectionResponse sr, FormSection section, List answers, + Map questionUuidById, Map optionById) { List answerDTOs = answers.stream() .map(a -> QuestionResponseDTO.builder() .questionResponseId(a.getQuestionResponseId()) .questionId(a.getQuestionId()) + .questionUuid(questionUuidById.get(a.getQuestionId())) .optionId(a.getOptionId()) + .optionValue(optionById.containsKey(a.getOptionId()) + ? optionById.get(a.getOptionId()).getOptionValue() : null) + .optionUuid(optionById.containsKey(a.getOptionId()) + ? optionById.get(a.getOptionId()).getOptionUuid() : null) .answerText(a.getAnswerText()) .build()) .collect(Collectors.toList()); 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 cdeaf11af..3c00b1973 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -30,6 +30,7 @@ import com.iemr.flw.repo.iemr.StopTBGeneralOpdRepo; import com.iemr.flw.repo.iemr.TBScreeningRepo; import com.iemr.flw.service.CampConfigService; +import com.iemr.flw.service.StopTBPrescriptionService; import com.iemr.flw.service.StopTBService; import com.iemr.flw.service.TBStopVisitService; import org.slf4j.Logger; @@ -51,6 +52,9 @@ public class StopTBServiceImpl implements StopTBService { @Autowired private CampConfigService campConfigService; + @Autowired + private StopTBPrescriptionService stopTBPrescriptionService; + @Autowired private BenFlowStatusRepo benFlowStatusRepo; @@ -254,6 +258,13 @@ public List> saveNurseTBScreening(List> screening.setKeyPopulationRiskFactors(toJsonString(data.get("keyPopulationRiskFactors"))); screening.setHivStatusId(getInt(data, "hivStatusId")); screening.setHivStatus(getString(data, "hivStatus")); + screening.setChestPain(getBool(data, "chestPain")); + screening.setShortnessOfBreath(getBool(data, "shortnessOfBreath")); + screening.setFatigue(getBool(data, "fatigue")); + screening.setFailureToGainWeight(getBool(data, "failureToGainWeightInChildren")); + screening.setDecreasedActivityOrPlayfulness(getBool(data, "decreasedActivityOrPlayfulnessInChildren")); + screening.setOthers(getBool(data, "otherSymptoms")); + String symptomaticInput = getString(data, "symptomatic"); if ("Yes".equalsIgnoreCase(symptomaticInput)) { screening.setSympotomatic(null); @@ -349,23 +360,59 @@ private Map screeningToMap(TBScreening s) { m.put("visitDate", s.getVisitDate()); m.put("updateDate", s.getLastModDate()); m.put("updatedBy", s.getModifiedBy()); + m.put("chestPain", s.getChestPain()); + m.put("shortnessOfBreath", s.getShortnessOfBreath()); + m.put("fatigue", s.getFatigue()); + m.put("failureToGainWeightInChildren", s.getFailureToGainWeight()); + m.put("decreasedActivityOrPlayfulnessInChildren", s.getDecreasedActivityOrPlayfulness()); + m.put("otherSymptoms", s.getOthers()); return m; } // ── Nurse: General OPD ──────────────────────────────────────────────────── + // Whole list is one transaction: any failure (incl. insufficient stock) rolls back every record. @Override - @Transactional + @Transactional(rollbackFor = Exception.class) public List> saveGeneralOpd(List> dataList) throws Exception { List> results = new ArrayList<>(); - Integer vanID = campConfigService.getVanID(); Integer parkingPlaceID = campConfigService.getParkingPlaceID(); for (Map data : dataList) { Long beneficiaryRegID = getLong(data, "beneficiaryRegID"); if (beneficiaryRegID == null) throw new Exception("beneficiaryRegID is required"); + Integer vanID = campConfigService.resolveVanID(getInt(data, "vanID")); Integer providerServiceMapID = getInt(data, "providerServiceMapID"); String createdBy = getString(data, "createdBy"); + + // New payload (chiefComplaints[] / prescription.drugs[]): AMRIT standard tables only, with dispensing. + // tb_stoptb_general_opd is not written; getAll and the Nikshay export read the standard tables. + if (data.get("chiefComplaints") instanceof List || data.get("prescription") instanceof Map) { + if (providerServiceMapID == null) throw new Exception("providerServiceMapID is required"); + if (createdBy == null || createdBy.isBlank()) throw new Exception("createdBy is required"); + String submissionId = getString(data, "submissionId"); + Map previous = stopTBPrescriptionService.findPreviousSubmission(beneficiaryRegID, submissionId); + if (previous != null) { + results.add(previous); + continue; + } + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, + createdBy, vanID, parkingPlaceID); + Map result = new LinkedHashMap<>(); + result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", visit.getVisitCode()); + stopTBPrescriptionService.saveChiefComplaints(toMapList(data.get("chiefComplaints")), beneficiaryRegID, + visit, createdBy, vanID, parkingPlaceID); + if (data.get("prescription") instanceof Map) { + result.putAll(stopTBPrescriptionService.prescribeAndDispense( + (Map) data.get("prescription"), beneficiaryRegID, visit, submissionId, + createdBy, vanID, parkingPlaceID)); + } + results.add(result); + continue; + } + + // Old app payload (free-text chiefComplaint / medication): unchanged behaviour. BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, createdBy, vanID, parkingPlaceID); @@ -403,13 +450,30 @@ public List> saveGeneralOpd(List> dataLi return results; } + @SuppressWarnings("unchecked") + private List> toMapList(Object v) { + return v instanceof List ? (List>) v : null; + } + + // Standard tables first (visits saved with the new payload); for older visits, which exist only in + // tb_stoptb_general_opd, fall back to that table. Same response fields either way. @Override public Map getAllGeneralOpd(Integer providerServiceMapID, Integer villageID) throws Exception { + List> items = stopTBPrescriptionService.getStandardOpdRecords(providerServiceMapID, villageID); + Set standardVisitCodes = new HashSet<>(); + for (Map item : items) standardVisitCodes.add((Long) item.get("visitCode")); + List list = (villageID != null) ? generalOpdRepo.findAllByProviderServiceMapIDAndVillageID(providerServiceMapID, villageID) : generalOpdRepo.findAllByProviderServiceMapID(providerServiceMapID); - List> items = new ArrayList<>(); - for (StopTBGeneralOpd o : list) items.add(opdToMap(o)); + for (StopTBGeneralOpd o : list) { + if (o.getVisitCode() == null || !standardVisitCodes.contains(o.getVisitCode())) items.add(opdToMap(o)); + } + items.sort((a, b) -> { + Date da = (Date) a.get("createdDate"), db = (Date) b.get("createdDate"); + if (da == null || db == null) return da == null ? (db == null ? 0 : 1) : -1; + return db.compareTo(da); + }); Map result = new HashMap<>(); result.put("data", items); result.put("count", items.size()); @@ -427,6 +491,7 @@ private Map opdToMap(StopTBGeneralOpd o) { m.put("dosage", o.getDosage()); m.put("frequency", o.getFrequency()); m.put("duration", o.getDuration()); + m.put("drugs", Collections.emptyList()); // old free-text records have no per-drug details m.put("notes", o.getNotes()); m.put("createdBy", o.getCreatedBy()); m.put("createdDate", o.getCreatedDate()); @@ -514,6 +579,61 @@ public Map getAllDiagnostics(Integer providerServiceMapID, Integ return result; } + // ── By id, for the changes feed ─────────────────────────────────────────── + + @Override + public Map> getNurseTBScreeningsByIds(Collection ids) { + Map> out = new LinkedHashMap<>(); + for (TBScreening s : tbScreeningRepo.findAllById(ids)) { + if (!Boolean.TRUE.equals(s.getDeleted())) out.put(s.getId(), screeningToMap(s)); + } + return out; + } + + @Override + public Map> getGeneralExaminationsByIds(Collection ids) { + Map> out = new LinkedHashMap<>(); + for (StopTBGeneralExamination e : generalExaminationRepo.findAllById(ids)) { + if (!Boolean.TRUE.equals(e.getDeleted())) out.put(e.getId(), examToMap(e)); + } + return out; + } + + @Override + public Map> getDiagnosticsByIds(Collection ids) { + Map> out = new LinkedHashMap<>(); + for (StopTBDiagnostics d : diagnosticsRepo.findAllById(ids)) { + if (!Boolean.TRUE.equals(d.getDeleted())) out.put(d.getId(), diagnosticsToMap(d)); + } + return out; + } + + @Override + public Map> getGeneralOpdByVisitIds(Collection benVisitIds) { + Map> out = new LinkedHashMap<>(); + for (Map m : stopTBPrescriptionService.getStandardOpdRecordsByVisitIds(benVisitIds)) { + out.put((Long) m.get("id"), m); + } + return out; + } + + @Override + public Map> getLegacyGeneralOpdByIds(Collection ids) { + List rows = new ArrayList<>(); + Set visitCodes = new HashSet<>(); + for (StopTBGeneralOpd o : generalOpdRepo.findAllById(ids)) { + if (Boolean.TRUE.equals(o.getDeleted())) continue; + rows.add(o); + if (o.getVisitCode() != null) visitCodes.add(o.getVisitCode()); + } + Set standard = stopTBPrescriptionService.visitCodesWithStandardOpd(visitCodes); + Map> out = new LinkedHashMap<>(); + for (StopTBGeneralOpd o : rows) { + if (o.getVisitCode() == null || !standard.contains(o.getVisitCode())) out.put(o.getId(), opdToMap(o)); + } + return out; + } + private Map diagnosticsToMap(StopTBDiagnostics d) { Map m = new LinkedHashMap<>(); m.put("id", d.getId()); @@ -562,10 +682,33 @@ private Map getRegistrationExtras(Long beneficiaryRegID) { return Collections.emptyMap(); } + // Latest non-null height/weight/bmi/temperatureValue for the beneficiary. Identity-API's + // syncDataToAmrit writes these to t_phy_anthropometry / t_phy_vitals at registration; + // otherFields is only a fallback for beneficiaries registered before that. + private Map getLatestAnthropometry(Long beneficiaryRegID) { + Map values = new HashMap<>(); + for (BenAnthropometryDetail a : benAnthropometryRepo.findByBeneficiaryRegIDOrderByCreatedDateDesc(beneficiaryRegID)) { + if (a.getHeightCm() != null) values.putIfAbsent("height", a.getHeightCm()); + if (a.getWeightKg() != null) values.putIfAbsent("weight", a.getWeightKg()); + if (a.getBmi() != null) values.putIfAbsent("bmi", a.getBmi()); + } + for (BenPhysicalVitalDetail v : benPhysicalVitalRepo.findByBeneficiaryRegIDOrderByCreatedDateDesc(beneficiaryRegID)) { + if (v.getTemperature() != null) { + values.put("temperatureValue", v.getTemperature()); + break; + } + } + Map extras = getRegistrationExtras(beneficiaryRegID); + for (String key : List.of("height", "weight", "bmi", "temperatureValue")) { + if (extras.get(key) instanceof Number) values.putIfAbsent(key, extras.get(key)); + } + return values; + } + private void dualWriteExamToStandardTables(StopTBGeneralExamination exam, Long beneficiaryRegID, BenVisitDetail visit, String createdBy, Integer vanID, Integer parkingPlaceID) { try { - Map extras = getRegistrationExtras(beneficiaryRegID); + Map extras = getLatestAnthropometry(beneficiaryRegID); writeAnthropometry(extras, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); writeVitals(exam, extras, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); writePhyGeneralExam(exam, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); @@ -579,6 +722,9 @@ private void dualWriteExamToStandardTables(StopTBGeneralExamination exam, Long b private void writeAnthropometry(Map extras, Long beneficiaryRegID, BenVisitDetail visit, String createdBy, Integer vanID, Integer parkingPlaceID) { + // An all-null row would become the latest one and hide the registration values + if (!(extras.get("height") instanceof Number) && !(extras.get("weight") instanceof Number) + && !(extras.get("bmi") instanceof Number)) return; try { BenAnthropometryDetail a = new BenAnthropometryDetail(); a.setBeneficiaryRegID(beneficiaryRegID); diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java new file mode 100644 index 000000000..4b144d60a --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/StopTBSyncServiceImpl.java @@ -0,0 +1,483 @@ +package com.iemr.flw.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParser; +import com.iemr.flw.service.BeneficiaryService; +import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.service.DynamicFormResponseService; +import com.iemr.flw.service.StopTBService; +import com.iemr.flw.service.StopTBSyncService; +import com.iemr.flw.service.TBConfirmedCaseService; +import com.iemr.flw.service.TBSuspectedService; +import com.iemr.flw.service.sync.StopTBSyncType; +import com.iemr.flw.service.sync.SyncToken; +import com.iemr.flw.service.sync.UserSyncScope; +import com.iemr.flw.repo.iemr.UserServiceRoleRepo; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * STOP-TB changes feed. AMRIT-DB V132 / V32 install t_sync_change_log and its triggers on every + * server; tablets call the feed on their camp hub (or UAT central when testing). + * + * The tablet keeps one opaque token. With none it gets a first full download in pages (snapshot); + * after that, each call returns only the records with change-log lines after its token, in their + * current state, plus the next token. Village scope is resolved here through i_ben_flow_outreach, + * the same way today's getAll endpoints do. + * + * @author Piramal Swasthya + */ +@Service +public class StopTBSyncServiceImpl implements StopTBSyncService { + + private final Logger logger = LoggerFactory.getLogger(StopTBSyncServiceImpl.class); + + static final int DEFAULT_LIMIT = 200; + static final int MAX_LIMIT = 500; + /** + * Beneficiary records are the expensive ones (several identity lookups and a FHIR call each), so a + * page carries at most this many; the token resumes from the last one sent. + */ + static final int MAX_BENEFICIARIES_PER_PAGE = 25; + /** Margin under the oldest open transaction (see safePoint); lines younger than this wait. */ + static final int SAFE_LAG_SECONDS = 5; + /** The hub keeps 30 days of log; a token older than this may point at cleaned-up lines. */ + static final long MAX_TOKEN_AGE_SECONDS = 29L * 24 * 3600; + private static final long SUPPORT_CHECK_TTL_MS = 5 * 60 * 1000L; + private static final String FLOW_TABLE = "db_iemr.i_ben_flow_outreach"; + + // Same date formats the existing getAll endpoints use for these records. + private final Gson stopTbGson = new GsonBuilder().serializeNulls().create(); + + @PersistenceContext(unitName = "db_iemr") + private EntityManager em; + + @Autowired + private BeneficiaryService beneficiaryService; + @Autowired + private StopTBService stopTBService; + @Autowired + private TBSuspectedService tbSuspectedService; + @Autowired + private TBConfirmedCaseService tbConfirmedCaseService; + @Autowired + private DynamicFormResponseService dynamicFormResponseService; + @Autowired + private DiagnosticOrderService diagnosticOrderService; + @Autowired + private ObjectMapper objectMapper; + + private static final long USER_SCOPE_TTL_MS = 5 * 60 * 1000L; + private final Map> userScopes = new java.util.concurrent.ConcurrentHashMap<>(); + + @Autowired + private UserServiceRoleRepo userServiceRoleRepo; + + private volatile boolean innodbTrxWarned; + private volatile Boolean supported; + private volatile long supportCheckedAt; + + @Override + public Map getChanges(Integer userId, String token, Integer providerServiceMapID, + List villageIds, List types, Integer limit, + String authorisation) { + if (!isSupported()) { + Map out = new LinkedHashMap<>(); + out.put("supported", false); + return out; + } + if (userId == null) throw new IllegalArgumentException("A valid JwtToken is required"); + if (providerServiceMapID == null) throw new IllegalArgumentException("providerServiceMapID is required"); + // Only the user's own service and villages; none asked for means all of them. + List villages = userScope(userId).resolve(providerServiceMapID, villageIds); + int pageSize = limit == null || limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); + + String serverId = serverId(); + SyncToken parsed = SyncToken.parse(token); + long now = System.currentTimeMillis() / 1000; + boolean usable = parsed != null && serverId.equals(parsed.serverId()) + && (parsed.kind() == SyncToken.Kind.SNAPSHOT || now - parsed.issuedAt() <= MAX_TOKEN_AGE_SECONDS); + + Scope scope = new Scope(providerServiceMapID, villages, requestedTypes(types)); + // A token covers only the types and villages it was issued for; anything else needs a snapshot. + usable = usable && parsed.typesMask() == scope.typesMask() && parsed.scopeHash() == scope.scopeHash(); + if (!usable) { + if (parsed != null) logger.info("Sync token {} not usable here, starting a snapshot", parsed); + return snapshotPage(serverId, SyncToken.snapshot(serverId, safePoint(), 0, 0, scope.typesMask(), scope.scopeHash()), + scope, pageSize, authorisation); + } + return parsed.kind() == SyncToken.Kind.SNAPSHOT + ? snapshotPage(serverId, parsed, scope, pageSize, authorisation) + : deltaPage(serverId, parsed.seq(), scope, pageSize, authorisation); + } + + // ── Snapshot: everything in scope, type by type, keyset-paged ───────────── + + private Map snapshotPage(String serverId, SyncToken at, Scope scope, int pageSize, + String authorisation) { + StopTBSyncType[] types = StopTBSyncType.values(); + int typeIndex = at.typeIndex(); + long after = at.afterKey(); + List> items = new ArrayList<>(); + + while (items.size() < pageSize && typeIndex < types.length) { + StopTBSyncType type = types[typeIndex]; + if (!scope.types.contains(type)) { + typeIndex++; + after = 0; + continue; + } + int want = pageSize - items.size(); + if (type == StopTBSyncType.BENEFICIARY) want = Math.min(want, MAX_BENEFICIARIES_PER_PAGE); + List keys = snapshotKeys(type, scope, after, want); + Map records = build(type, keys, authorisation); + for (Long key : keys) { + String data = records.get(key); + if (data != null) items.add(item(type, "upsert", key, at.seq(), data)); + } + if (keys.size() < want) { + typeIndex++; + after = 0; + } else { + after = keys.get(keys.size() - 1); + break; + } + } + + boolean done = typeIndex >= types.length; + SyncToken next = done + ? SyncToken.delta(serverId, at.seq(), System.currentTimeMillis() / 1000, scope.typesMask(), scope.scopeHash()) + : SyncToken.snapshot(serverId, at.seq(), typeIndex, after, scope.typesMask(), scope.scopeHash()); + return page(serverId, true, items, next, !done); + } + + @SuppressWarnings("unchecked") + private List snapshotKeys(StopTBSyncType type, Scope scope, long after, int limit) { + List rows = em.createNativeQuery(type.snapshotKeySql()) + .setParameter("psm", scope.psm) + .setParameter("villages", scope.villages) + .setParameter("after", after) + .setParameter("limit", limit) + .getResultList(); + List keys = new ArrayList<>(rows.size()); + for (Object r : rows) keys.add(((Number) r).longValue()); + return keys; + } + + // ── Delta: records with log lines after the token ───────────────────────── + + @SuppressWarnings("unchecked") + private Map deltaPage(String serverId, long from, Scope scope, int pageSize, + String authorisation) { + long safe = Math.max(safePoint(), from); + // One row per record: its latest line in range, and that line's op. Deduplicates a record + // changed many times since the token. + List rows = em.createNativeQuery( + "SELECT l.entity, l.row_pk, l.seq, l.op, g.scoped FROM db_iemr.t_sync_change_log l JOIN (" + + " SELECT c.entity, c.row_pk, MAX(c.seq) AS last_seq, MAX(c.op = 'S') AS scoped " + + " FROM db_iemr.t_sync_change_log c " + + " WHERE c.seq > :from AND c.seq <= :safe AND c.entity IN (:entities) AND (" + + " (c.ben_reg_id IS NOT NULL AND EXISTS (SELECT 1 FROM db_iemr.i_ben_flow_outreach f " + + " WHERE f.beneficiary_reg_id = c.ben_reg_id AND f.providerServiceMapID = :psm AND f.villageID IN (:villages)))" + + " OR (c.beneficiary_id IS NOT NULL AND EXISTS (SELECT 1 FROM db_iemr.i_ben_flow_outreach f " + + " WHERE f.beneficiary_id = c.beneficiary_id AND f.providerServiceMapID = :psm AND f.villageID IN (:villages))))" + + " GROUP BY c.entity, c.row_pk ORDER BY last_seq LIMIT :limit" + + ") g ON l.seq = g.last_seq ORDER BY l.seq") + .setParameter("from", from) + .setParameter("safe", safe) + .setParameter("entities", scope.logEntities()) + .setParameter("psm", scope.psm) + .setParameter("villages", scope.villages) + .setParameter("limit", pageSize) + .getResultList(); + boolean full = rows.size() == pageSize; + + // Keep beneficiary records per page bounded: stop before the one over the cap. The token then + // resumes from the last row kept, so nothing after it is skipped. + int beneficiaries = 0; + for (int i = 0; i < rows.size(); i++) { + if (!StopTBSyncType.BENEFICIARY.logEntity().equals(rows.get(i)[0])) continue; + if (++beneficiaries > MAX_BENEFICIARIES_PER_PAGE) { + rows = new ArrayList<>(rows.subList(0, i)); + full = true; + break; + } + } + + // Build each type's records in one batch, then emit in log order. + Map> keysByType = new EnumMap<>(StopTBSyncType.class); + for (Object[] r : rows) { + StopTBSyncType type = StopTBSyncType.fromLogEntity((String) r[0]); + if (type != null) keysByType.computeIfAbsent(type, t -> new ArrayList<>()).add(((Number) r[1]).longValue()); + } + Map> built = new EnumMap<>(StopTBSyncType.class); + keysByType.forEach((type, keys) -> built.put(type, build(type, keys, authorisation))); + + List> items = new ArrayList<>(); + // Beneficiaries that came into scope (op 'S': new flow row, or a village / service change): + // their records logged before that never passed the scope check, so send them now. + List scopedRegIds = new ArrayList<>(); + Map scopedSeq = new HashMap<>(); + for (Object[] r : rows) { + if (StopTBSyncType.BENEFICIARY.logEntity().equals(r[0]) && r[4] != null && ((Number) r[4]).intValue() == 1) { + long regId = ((Number) r[1]).longValue(); + scopedRegIds.add(regId); + scopedSeq.put(regId, ((Number) r[2]).longValue()); + } + } + long lastSeq = from; + for (Object[] r : rows) { + lastSeq = ((Number) r[2]).longValue(); + StopTBSyncType type = StopTBSyncType.fromLogEntity((String) r[0]); + if (type == null) continue; + long key = ((Number) r[1]).longValue(); + String data = built.get(type).get(key); + String op = r[3] == null ? "U" : r[3].toString(); + if (data != null && !"D".equals(op)) { + items.add(item(type, "upsert", key, lastSeq, data)); + } else if ("D".equals(op) || type != StopTBSyncType.BENEFICIARY) { + // Gone, soft-deleted or no longer a record the app keeps (e.g. an OPD visit with no + // complaint or drug). A beneficiary that just failed to build is never deleted on the + // tablet; only a deleted mapping removes it. + items.add(item(type, "delete", key, lastSeq, null)); + } + } + + if (!scopedRegIds.isEmpty()) { + long version = scopedSeq.values().stream().mapToLong(Long::longValue).max().orElse(lastSeq); + for (StopTBSyncType type : scope.types) { + if (type == StopTBSyncType.BENEFICIARY) continue; + List keys = keysForBeneficiaries(type, scope, scopedRegIds); + List alreadySent = keysByType.getOrDefault(type, List.of()); + keys.removeAll(alreadySent); + if (keys.isEmpty()) continue; + Map records = build(type, keys, authorisation); + for (Long key : keys) { + String data = records.get(key); + if (data != null) items.add(item(type, "upsert", key, version, data)); + } + } + } + + boolean hasMore = full; + long nextSeq = hasMore ? lastSeq : safe; + return page(serverId, false, items, + SyncToken.delta(serverId, nextSeq, System.currentTimeMillis() / 1000, scope.typesMask(), scope.scopeHash()), hasMore); + } + + @SuppressWarnings("unchecked") + private List keysForBeneficiaries(StopTBSyncType type, Scope scope, List regIds) { + jakarta.persistence.Query q = em.createNativeQuery(type.keysForBeneficiariesSql()).setParameter("regIds", regIds); + if (type.keysForBeneficiariesSql().contains(":psm")) q.setParameter("psm", scope.psm); + List rows = q.getResultList(); + List keys = new ArrayList<>(rows.size()); + for (Object r : rows) keys.add(((Number) r).longValue()); + return keys; + } + + // ── Records, in the same JSON each type's existing endpoint returns ─────── + + private Map build(StopTBSyncType type, List keys, String authorisation) { + Map out = new HashMap<>(); + if (keys.isEmpty()) return out; + try { + switch (type) { + case BENEFICIARY: + return beneficiaryService.getBeneficiaryRecordsByRegIds(keys, authorisation); + case TB_SCREENING: + stopTBService.getNurseTBScreeningsByIds(keys).forEach((k, m) -> out.put(k, stopTbGson.toJson(m))); + return out; + case GENERAL_EXAMINATION: + stopTBService.getGeneralExaminationsByIds(keys).forEach((k, m) -> out.put(k, stopTbGson.toJson(m))); + return out; + case GENERAL_OPD: + stopTBService.getGeneralOpdByVisitIds(keys).forEach((k, m) -> out.put(k, stopTbGson.toJson(m))); + return out; + case GENERAL_OPD_LEGACY: + stopTBService.getLegacyGeneralOpdByIds(keys).forEach((k, m) -> out.put(k, stopTbGson.toJson(m))); + return out; + case DIAGNOSTICS: + stopTBService.getDiagnosticsByIds(keys).forEach((k, m) -> out.put(k, stopTbGson.toJson(m))); + return out; + case TB_SUSPECTED: + return tbSuspectedService.getRecordsByIds(keys); + case TB_CONFIRMED: + return tbConfirmedCaseService.getRecordsByIds(keys); + case FORM_RESPONSE: + for (Map.Entry e : dynamicFormResponseService.getResponsesByIds(keys).entrySet()) { + out.put(e.getKey(), objectMapper.writeValueAsString(e.getValue())); + } + return out; + case DIAGNOSTIC_ORDER: + for (Map.Entry> e : diagnosticOrderService.getOrderRecordsByIds(keys).entrySet()) { + out.put(e.getKey(), objectMapper.writeValueAsString(e.getValue())); + } + return out; + default: + return out; + } + } catch (Exception e) { + // A failed batch must not turn into deletes on the tablet: fail the whole call, so the + // token doesn't move and the tablet retries. + throw new IllegalStateException("Could not build " + type.apiName() + " records: " + e.getMessage(), e); + } + } + + private Map item(StopTBSyncType type, String op, long key, long version, String data) { + Map m = new LinkedHashMap<>(); + m.put("type", type.apiName()); + m.put("op", op); + m.put("key", key); + m.put("version", version); + // The record exactly as the type's existing endpoint returns it, so the app reuses its parser. + m.put("data", data == null ? null : JsonParser.parseString(data)); + return m; + } + + private Map page(String serverId, boolean snapshot, List> items, + SyncToken next, boolean hasMore) { + Map out = new LinkedHashMap<>(); + out.put("supported", true); + out.put("serverId", serverId); + out.put("snapshot", snapshot); + out.put("items", items); + out.put("nextToken", next.encode()); + out.put("hasMore", hasMore); + return out; + } + + // The user's services and villages from their role mappings, cached briefly because tablets poll. + private UserSyncScope userScope(Integer userId) { + long now = System.currentTimeMillis(); + Map.Entry cached = userScopes.get(userId); + if (cached != null && now - cached.getKey() < USER_SCOPE_TTL_MS) return cached.getValue(); + UserSyncScope scope = UserSyncScope.fromMappingRows(userServiceRoleRepo.getServiceVillageRows(userId)); + userScopes.put(userId, Map.entry(now, scope)); + return scope; + } + + // ── Hub state ───────────────────────────────────────────────────────────── + + private String serverId() { + return String.valueOf(em.createNativeQuery("SELECT @@server_uuid").getSingleResult()); + } + + /** + * The highest seq a reader may move past. seq is taken when a row is inserted, not when its + * transaction commits, so a transaction still open may hold a lower seq than lines already + * committed. Every line it writes is stamped at or after its start, so lines older than the oldest + * open transaction (information_schema.innodb_trx) are final; the lag also covers the gap between a + * statement's timestamp and its insert. A long open transaction holds the feed back until it ends. + */ + private long safePoint() { + try { + Object r = em.createNativeQuery("SELECT COALESCE(MAX(seq), 0) FROM db_iemr.t_sync_change_log " + + "WHERE changed_at <= (SELECT LEAST(NOW(6), COALESCE(MIN(trx_started), NOW(6))) " + + "FROM information_schema.innodb_trx) - INTERVAL " + SAFE_LAG_SECONDS + " SECOND") + .getSingleResult(); + return ((Number) r).longValue(); + } catch (Exception e) { + // innodb_trx needs the PROCESS privilege; without it only the fixed lag protects late commits. + if (!innodbTrxWarned) { + logger.warn("STOP-TB changes feed: cannot read information_schema.innodb_trx ({}); " + + "using a fixed {} s lag only", e.getMessage(), SAFE_LAG_SECONDS); + innodbTrxWarned = true; + } + Object r = em.createNativeQuery("SELECT COALESCE(MAX(seq), 0) FROM db_iemr.t_sync_change_log " + + "WHERE changed_at <= NOW(6) - INTERVAL " + SAFE_LAG_SECONDS + " SECOND") + .getSingleResult(); + return ((Number) r).longValue(); + } + } + + /** + * True only where the change log exists AND every source table has its insert, update and delete + * triggers. Otherwise a change could be missed silently, so the feed reports itself unsupported and + * the app keeps using the full pull. + */ + @SuppressWarnings("unchecked") + boolean isSupported() { + long now = System.currentTimeMillis(); + if (supported != null && now - supportCheckedAt < SUPPORT_CHECK_TTL_MS) return supported; + boolean ok; + try { + Number logTables = (Number) em.createNativeQuery("SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = 'db_iemr' AND table_name = 't_sync_change_log'").getSingleResult(); + Set withAllTriggers = new HashSet<>(); + List rows = em.createNativeQuery("SELECT LOWER(CONCAT(event_object_schema, '.', event_object_table)), " + + "COUNT(DISTINCT event_manipulation) FROM information_schema.triggers " + + "WHERE trigger_name LIKE 'trg\\_scl\\_%' GROUP BY event_object_schema, event_object_table").getResultList(); + for (Object[] r : rows) { + String table = (String) r[0]; + // The flow table only decides scope: insert and update are enough (rows are never deleted). + int needed = FLOW_TABLE.equals(table) ? 2 : 3; + if (((Number) r[1]).intValue() >= needed) withAllTriggers.add(table); + } + List missing = new ArrayList<>(); + for (StopTBSyncType type : StopTBSyncType.values()) { + for (String table : type.sourceTables()) { + if (!withAllTriggers.contains(table)) missing.add(table); + } + } + ok = logTables.intValue() > 0 && missing.isEmpty(); + if (logTables.intValue() > 0 && !missing.isEmpty()) { + logger.error("STOP-TB changes feed disabled: change-log triggers missing on {}", missing); + } + } catch (Exception e) { + logger.warn("STOP-TB changes feed: could not check the change log: {}", e.getMessage()); + ok = false; + } + supported = ok; + supportCheckedAt = now; + return ok; + } + + private static Set requestedTypes(List names) { + Set out = EnumSet.noneOf(StopTBSyncType.class); + if (names == null || names.isEmpty()) { + out.addAll(EnumSet.allOf(StopTBSyncType.class)); + return out; + } + for (StopTBSyncType t : StopTBSyncType.values()) { + if (names.contains(t.apiName())) out.add(t); + } + if (out.isEmpty()) throw new IllegalArgumentException("No known record types in " + names); + return out; + } + + private static final class Scope { + final Integer psm; + final List villages; + final Set types; + + Scope(Integer psm, List villages, Set types) { + this.psm = psm; + this.villages = villages; + this.types = types; + } + + long scopeHash() { + return UserSyncScope.scopeHash(psm, villages); + } + + long typesMask() { + long mask = 0; + for (StopTBSyncType t : types) mask |= 1L << t.ordinal(); + return mask; + } + + List logEntities() { + List out = new ArrayList<>(); + for (StopTBSyncType t : types) out.add(t.logEntity()); + return out; + } + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java index faf95f2fd..2db58ede3 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java @@ -1,15 +1,15 @@ package com.iemr.flw.service.impl; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.iemr.flw.domain.iemr.*; -import com.iemr.flw.dto.iemr.TbReferralFollowUpDTO; -import com.iemr.flw.dto.iemr.TbReferralFollowUpListDTO; -import com.iemr.flw.dto.iemr.TbTptFollowUpDTO; -import com.iemr.flw.dto.iemr.TbTptFollowUpListDTO; +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.TBConfirmedCaseDTO; +import com.iemr.flw.domain.iemr.TBConfirmedCase; +import com.iemr.flw.domain.iemr.BenVisitDetail; import com.iemr.flw.repo.identity.BeneficiaryRepo; -import com.iemr.flw.repo.iemr.*; +import com.iemr.flw.repo.iemr.DynamicFormRepo; +import com.iemr.flw.repo.iemr.FormResponseRepo; +import com.iemr.flw.repo.iemr.TBConfirmedTreatmentRepository; import com.iemr.flw.seeder.TbCounsellingV2FormSeeder; import com.iemr.flw.service.IncentiveLogicService; import com.iemr.flw.service.CampConfigService; @@ -25,9 +25,12 @@ import java.sql.Timestamp; import java.time.LocalDate; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.*; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -54,14 +57,6 @@ public class TBConfirmedCaseServiceImpl implements TBConfirmedCaseService { @Autowired private BeneficiaryRepo beneficiaryRepo; - @Autowired - private TbReferralFollowUpRepo tbReferralFollowUpRepo; - - @Autowired - private IncentiveLogicImpl incentiveLogic; - - @Autowired - private TbTptFollowUpRepo tbTptFollowUpRepo; public TBConfirmedCaseServiceImpl(TBConfirmedTreatmentRepository repository, FormResponseRepo formResponseRepo, DynamicFormRepo dynamicFormRepo) { @@ -146,37 +141,21 @@ private void checkIncentive(TBConfirmedCase entity){ || "Longer Regimen (18–24 Months)".equalsIgnoreCase(regimen); if (Boolean.TRUE.equals(entity.getTreatmentCompleted()) - && entity.getFollowUpDate() != null) { + && entity.getExpectedTreatmentCompletionDate() != null) { if (isDrTb) { - if("Shorter Regimen (9–12 Months)".equalsIgnoreCase(regimen)){ - if ("cured".equalsIgnoreCase(entity.getTreatmentOutcome())) { - incentiveLogicService.incentiveForTbFollowUpIsDrTb( - entity.getBenId(), - Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), - Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), - entity.getUserId() - ); - } - - }else if("Longer Regimen (18–24 Months)".equalsIgnoreCase(regimen)){ - if ("cured".equalsIgnoreCase(entity.getTreatmentOutcome())) { - - incentiveLogicService.incentiveForTbFollowUpIsDrTb( - entity.getBenId(), - Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), - Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), - entity.getUserId() - ); - } - } - + incentiveLogicService.incentiveForTbFollowUpIsDrTb( + entity.getBenId(), + Timestamp.valueOf(entity.getExpectedTreatmentCompletionDate().atStartOfDay()), + Timestamp.valueOf(entity.getExpectedTreatmentCompletionDate().atStartOfDay()), + entity.getUserId() + ); } else { incentiveLogicService.incentiveForTbFollowUp( entity.getBenId(), - Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), - Timestamp.valueOf(entity.getFollowUpDate().atStartOfDay()), + Timestamp.valueOf(entity.getExpectedTreatmentCompletionDate().atStartOfDay()), + Timestamp.valueOf(entity.getExpectedTreatmentCompletionDate().atStartOfDay()), entity.getUserId() ); @@ -185,24 +164,6 @@ private void checkIncentive(TBConfirmedCase entity){ } } - public boolean isEligibleForIncentive(LocalDate treatmentStartDate, LocalDate endDate,Integer minMonth) { - - if (treatmentStartDate == null || endDate == null) { - return false; - } - - LocalDate start = treatmentStartDate.atStartOfDay() - .atZone(ZoneId.systemDefault()) - .toLocalDate(); - - LocalDate end = endDate.atStartOfDay() - .atZone(ZoneId.systemDefault()) - .toLocalDate(); - - return end.isAfter(start.plusMonths(minMonth)) - || end.isEqual(start.plusMonths(minMonth)); - } - @Override public String getByBenId(Long benId, String authorisation) throws Exception { OutputResponse response = new OutputResponse(); @@ -244,430 +205,24 @@ public String getByProviderServiceMapId(Integer providerServiceMapID, Integer vi } @Override - public String saveReferralFollowUp(TbReferralFollowUpDTO requestDTO, String token) { - OutputResponse response = new OutputResponse(); - - try { - - TbReferralFollowUp tbReferralFollowUp = new TbReferralFollowUp(); - - if(!token.isEmpty() && token!=null && requestDTO!=null){ - String userName = jwtUtil.extractUsername(token); - Integer userId = jwtUtil.extractUserId(token); - tbReferralFollowUp.setBenId(requestDTO.getBenId()); - tbReferralFollowUp.setHouseHoldId(requestDTO.getHouseHoldId()); - tbReferralFollowUp.setSyncedBy(userName); - tbReferralFollowUp.setUpdatedBy(userName); - tbReferralFollowUp.setCreatedBy(userName); - tbReferralFollowUp.setUserId(userId); - if(requestDTO.getFields().getReferredOnDate()!=null && !requestDTO.getFields().getFollowUpDate().isEmpty()){ - Timestamp referOnDate = Timestamp.valueOf(requestDTO.getFields().getReferredOnDate()); - tbReferralFollowUp.setFollowUpDate(referOnDate); - - } - - if(requestDTO.getFields().getFollowUpDate()!=null && !requestDTO.getFields().getFollowUpDate().isEmpty()){ - Timestamp followUpDate = Timestamp.valueOf(requestDTO.getFields().getFollowUpDate()); - tbReferralFollowUp.setFollowUpDate(followUpDate); - - } - tbReferralFollowUp.setFollowUpStatus(requestDTO.getFields().getFollowUpStatus()); - } - - tbReferralFollowUpRepo.save(tbReferralFollowUp); - response.setResponse("TB Referral follow up saved successfully"); - - - - }catch (Exception e){ - response.setError(500,e.getMessage()); - return response.toString(); - } - - - return response.toString(); - } - - @Override - public String getReferralFollowUp(String token) { - OutputResponse response = new OutputResponse(); - - try { - if (token == null || token.trim().isEmpty()) { - response.setError(400, "Token is required"); - return response.toString(); - } - - Integer userId = jwtUtil.extractUserId(token); - - List referrals = - tbReferralFollowUpRepo.findByUserId(userId); - - List referralDTOs = new ArrayList<>(); - - DateTimeFormatter formatter = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - for (TbReferralFollowUp referral : referrals) { - - TbReferralFollowUpDTO dto = new TbReferralFollowUpDTO(); - dto.setBenId(referral.getBenId()); - dto.setUserId(referral.getUserId()); - dto.setHouseHoldId(referral.getHouseHoldId()); - - // Initialize nested DTO before setting fields - TbReferralFollowUpListDTO fields = - new TbReferralFollowUpListDTO(); - - if (referral.getReferredOnDate() != null) { - fields.setReferredOnDate( - referral.getReferredOnDate() - .toLocalDateTime() - .format(formatter) - ); - } - - if (referral.getFollowUpDate() != null) { - fields.setFollowUpDate( - referral.getFollowUpDate() - .toLocalDateTime() - .format(formatter) - ); - } - - fields.setFollowUpStatus(referral.getFollowUpStatus()); - - dto.setFields(fields); - referralDTOs.add(dto); - } - - ObjectMapper objectMapper = new ObjectMapper(); - - response.setResponse( - objectMapper.writeValueAsString(referralDTOs) - ); - - } catch (Exception e) { - // Replace with your project's logger if available - e.printStackTrace(); - response.setError(500, "Failed to fetch referral follow-up: " + e.getMessage()); - } - - return response.toString(); - } - - @Override - public String saveTptFollowUp(TbTptFollowUpDTO requestDTO, String token) { - - OutputResponse response = new OutputResponse(); - - try { - - TbTptFollowUp tbTptFollowUp = new TbTptFollowUp(); - - if (token != null && !token.isEmpty() && requestDTO != null) { - - String userName = jwtUtil.extractUsername(token); - Integer userId = jwtUtil.extractUserId(token); - - tbTptFollowUp.setBenId(requestDTO.getBenId()); - tbTptFollowUp.setUserId(userId); - - tbTptFollowUp.setCreatedBy(userName); - tbTptFollowUp.setUpdatedBy(userName); - - if (requestDTO.getFields() != null) { - - TbTptFollowUpListDTO fields = requestDTO.getFields(); - - tbTptFollowUp.setRegimenType(fields.getRegimenType()); - - if (fields.getTreatmentStartDate() != null - && !fields.getTreatmentStartDate().isEmpty()) { - - tbTptFollowUp.setTreatmentStartDate( - Timestamp.valueOf(fields.getTreatmentStartDate()) - ); - } - - if (fields.getExpectedTreatmentCompletionDate() != null - && !fields.getExpectedTreatmentCompletionDate().isEmpty()) { - - tbTptFollowUp.setExpectedTreatmentCompletionDate( - Timestamp.valueOf(fields.getExpectedTreatmentCompletionDate()) - ); - } - - if (fields.getFollowUpDate() != null - && !fields.getFollowUpDate().isEmpty()) { - - tbTptFollowUp.setFollowUpDate( - Timestamp.valueOf(fields.getFollowUpDate()) - ); - } - - tbTptFollowUp.setFollowUpMonth( - fields.getMonthlyFollowUp() - ); - - tbTptFollowUp.setAdherenceToMedicines( - fields.getMedicineAdherence() - ); - - tbTptFollowUp.setAnyDiscomfort( - convertBollen(fields.getAnyDiscomfort()) - ); - - tbTptFollowUp.setTreatmentCompleted( - convertBollen(fields.getTreatmentCompleted()) - ); - - if (fields.getActualCompletionDate() != null - && !fields.getActualCompletionDate().isEmpty()) { - - tbTptFollowUp.setActualTreatmentCompletionDate( - Timestamp.valueOf(fields.getActualCompletionDate()) - ); - } - - tbTptFollowUp.setTptOutcome( - fields.getTptOutcome() - ); - - if ("Death".equalsIgnoreCase(fields.getTptOutcome())) { - - if (fields.getDateOfDeath() != null - && !fields.getDateOfDeath().isEmpty()) { - - tbTptFollowUp.setDateOfDeath( - Timestamp.valueOf(fields.getDateOfDeath()) - ); - } - - tbTptFollowUp.setPlaceOfDeath( - fields.getPlaceOfDeath() - ); - - tbTptFollowUp.setReasonForDeath( - fields.getReasonForDeath() - ); - } - } - - tbTptFollowUpRepo.save(tbTptFollowUp); - - createTptIncentiveIfEligible(tbTptFollowUp); - } - - response.setResponse("TPT follow up saved successfully"); - - } catch (Exception e) { - - response.setError(500, e.getMessage()); - return response.toString(); - } - - return response.toString(); + public Map getRecordsByIds(java.util.Collection ids) { + List intIds = ids.stream().map(Long::intValue).collect(Collectors.toList()); + List dtoList = withCounselled( + repository.findAllById(intIds).stream().map(this::toDTO).collect(Collectors.toList())); + Gson gson = confirmedGson(); + Map out = new java.util.LinkedHashMap<>(); + for (TBConfirmedCaseDTO dto : dtoList) out.put(dto.getId().longValue(), gson.toJson(dto)); + return out; } - - private static final Map MIN_FOLLOW_UPS = Map.of( - "1HP", 1, - "3HP", 3, - "3RH", 3, - "4R", 4, - "6H", 6, - "6LFX", 6 - ); - - - private void createTptIncentiveIfEligible(TbTptFollowUp saved) { - - // 1. Basic conditions - if (!Boolean.TRUE.equals(saved.getTreatmentCompleted()) - || saved.getActualTreatmentCompletionDate() == null - || saved.getRegimenType() == null - || saved.getTreatmentStartDate() == null - || saved.getBenId() == null) { - return; - } - - // 2. Regimen ka minimum follow-up count - Integer minRequired = MIN_FOLLOW_UPS.get(saved.getRegimenType().trim().toUpperCase()); - if (minRequired == null) { - return; // unknown regimen - } - - List cycleRows = tbTptFollowUpRepo - .findByBenIdAndRegimenTypeAndTreatmentStartDate( - saved.getBenId(), - saved.getRegimenType(), - saved.getTreatmentStartDate()); - - long completedMonths = cycleRows.stream() - .map(TbTptFollowUp::getFollowUpMonth) - .filter(Objects::nonNull) - .map(String::trim) - .distinct() - .count(); - - if (completedMonths < minRequired) { - return; - } - Timestamp completionDate = saved.getActualTreatmentCompletionDate(); - - incentiveLogic.incentiveForTbPreventiveFollowUp( - saved.getBenId(), - completionDate, - completionDate, - saved.getUserId()); - } - - @Override - public String getTptFollowUp(String token) { - - OutputResponse response = new OutputResponse(); - - try { - - if (token == null || token.trim().isEmpty()) { - response.setError(400, "Token is required"); - return response.toString(); - } - - Integer userId = jwtUtil.extractUserId(token); - - List tptFollowUps = - tbTptFollowUpRepo.findByUserId(userId); - - List tptFollowUpDTOs = - new ArrayList<>(); - - DateTimeFormatter formatter = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - for (TbTptFollowUp tptFollowUp : tptFollowUps) { - - TbTptFollowUpDTO dto = new TbTptFollowUpDTO(); - - dto.setBenId(tptFollowUp.getBenId()); - dto.setUserId(tptFollowUp.getUserId()); - - TbTptFollowUpListDTO fields = - new TbTptFollowUpListDTO(); - - // Regimen Type - fields.setRegimenType( - tptFollowUp.getRegimenType() - ); - - // Treatment Start Date - if (tptFollowUp.getTreatmentStartDate() != null) { - fields.setTreatmentStartDate( - tptFollowUp.getTreatmentStartDate() - .toLocalDateTime() - .format(formatter) - ); - } - - // Expected Treatment Completion Date - if (tptFollowUp.getExpectedTreatmentCompletionDate() != null) { - fields.setExpectedTreatmentCompletionDate( - tptFollowUp.getExpectedTreatmentCompletionDate() - .toLocalDateTime() - .format(formatter) - ); - } - - // Follow Up Date - if (tptFollowUp.getFollowUpDate() != null) { - fields.setFollowUpDate( - tptFollowUp.getFollowUpDate() - .toLocalDateTime() - .format(formatter) - ); - } - - // Monthly Follow Up - fields.setMonthlyFollowUp( - tptFollowUp.getFollowUpMonth() - ); - - // Medicine Adherence - fields.setMedicineAdherence( - tptFollowUp.getAdherenceToMedicines() - ); - - // Any Discomfort - fields.setAnyDiscomfort( - convert(tptFollowUp.getAnyDiscomfort()) - ); - - // Treatment Completed - fields.setTreatmentCompleted( - convert(tptFollowUp.getTreatmentCompleted()) - ); - - // Actual Completion Date - if (tptFollowUp.getActualTreatmentCompletionDate() != null) { - fields.setActualCompletionDate( - tptFollowUp.getActualTreatmentCompletionDate() - .toLocalDateTime() - .format(formatter) - ); - } - - // TPT Outcome - fields.setTptOutcome( - tptFollowUp.getTptOutcome() - ); - - // Date of Death - if (tptFollowUp.getDateOfDeath() != null) { - fields.setDateOfDeath( - tptFollowUp.getDateOfDeath() - .toLocalDateTime() - .format(formatter) - ); - } - - // Place of Death - fields.setPlaceOfDeath( - tptFollowUp.getPlaceOfDeath() - ); - - // Reason for Death - fields.setReasonForDeath( - tptFollowUp.getReasonForDeath() - ); - - dto.setFields(fields); - - tptFollowUpDTOs.add(dto); - } - - ObjectMapper objectMapper = new ObjectMapper(); - - response.setResponse( - objectMapper.writeValueAsString(tptFollowUpDTOs) - ); - - } catch (Exception e) { - - e.printStackTrace(); - - response.setError( - 500, - "Failed to fetch TPT follow-up: " + e.getMessage() - ); - } - - return response.toString(); + private Gson confirmedGson() { + return new GsonBuilder() + .registerTypeAdapter(LocalDate.class, new LocalDateAdapter()) + .setDateFormat("MMM dd, yyyy h:mm:ss a") + .create(); } - private String buildTbConfirmedCasesResponse(Integer userId, List list) { - List dtoList = list.stream().map(this::toDTO).collect(Collectors.toList()); - + private List withCounselled(List dtoList) { List benIds = dtoList.stream().map(TBConfirmedCaseDTO::getBenId).collect(Collectors.toList()); Set counselledBenIds = Collections.emptySet(); if (!benIds.isEmpty()) { @@ -678,15 +233,16 @@ private String buildTbConfirmedCasesResponse(Integer userId, List counselled = counselledBenIds; dtoList.forEach(dto -> dto.setCounselled(counselled.contains(dto.getBenId()))); + return dtoList; + } + + private String buildTbConfirmedCasesResponse(Integer userId, List list) { + List dtoList = withCounselled(list.stream().map(this::toDTO).collect(Collectors.toList())); Map response = new java.util.HashMap<>(); response.put("userId", userId); response.put("tbConfirmedCases", dtoList); - Gson gson = new GsonBuilder() - .registerTypeAdapter(LocalDate.class, new LocalDateAdapter()) - .setDateFormat("MMM dd, yyyy h:mm:ss a") - .create(); - return gson.toJson(response); + return confirmedGson().toJson(response); } // Utility: convert entity -> DTO @@ -715,17 +271,4 @@ private TBConfirmedCaseDTO toDTO(TBConfirmedCase entity) { return dto; } - - private String convert(Boolean value) { - if (value == null) return null; - return value ? "Yes" : "No"; - } - - private Boolean convertBollen(String value) { - if (value != null && !value.isEmpty()) { - return value.equalsIgnoreCase("Yes"); - } else { - return false; - } - } } diff --git a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java index 7297adfc1..d3f3e485f 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java @@ -25,7 +25,17 @@ public BenVisitDetail getOrCreateVisitForToday(Long beneficiaryRegID, Integer pr Timestamp dayEnd = Timestamp.valueOf(today.plusDays(1).atStartOfDay()); BenVisitDetail existing = benVisitDetailsRepo.findStopTBVisitForToday(beneficiaryRegID, dayStart, dayEnd); - if (existing != null) return existing; + if (existing != null) { + // TB suspected / confirmed / diagnostic saves create the visit without a service line; whichever + // form syncs first decides. Fill it from the first later save that has one (screening, OPD...). + if (existing.getProviderServiceMapID() == null && providerServiceMapID != null) { + existing.setProviderServiceMapID(providerServiceMapID); + existing.setModifiedBy(createdBy); + existing.setProcessed("N"); + existing = benVisitDetailsRepo.saveAndFlush(existing); + } + return existing; + } Integer visitCount = benVisitDetailsRepo.countStopTBVisits(beneficiaryRegID); short visitNo = (short) ((visitCount != null ? visitCount : 0) + 1); diff --git a/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java index 99db4fcbe..1f8fa0b8c 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java @@ -13,6 +13,8 @@ import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.TBStopVisitService; import com.iemr.flw.service.TBSuspectedService; +import com.iemr.flw.service.UserService; +import com.iemr.flw.masterEnum.StateCode; import org.modelmapper.ModelMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,7 +23,11 @@ import java.sql.Timestamp; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; @Service public class TBSuspectedServiceImpl implements TBSuspectedService { @@ -40,6 +46,9 @@ public class TBSuspectedServiceImpl implements TBSuspectedService { @Autowired private IncentiveLogicService incentiveLogicService; + @Autowired + private UserService userService; + @Override public String getByBenId(Long benId, String authorisation) throws Exception { return null; @@ -140,16 +149,115 @@ public String save(TBSuspectedRequestDTO requestDTO) throws Exception { @Override public String getByUserId(GetBenRequestHandler request) { List dtos = new ArrayList<>(); - List tbSuspectedList = request.getProviderServiceMapID() != null + boolean stopTbVillage = request.getProviderServiceMapID() != null; + List tbSuspectedList = stopTbVillage ? tbSuspectedRepo.getByProviderServiceMapIdAndVillageId(request.getProviderServiceMapID(), request.getVillageID()) : tbSuspectedRepo.getByUserId(request.getAshaId(), request.getFromDate(), request.getToDate()); + if (stopTbVillage) { + dtos = stopTbVillageDtos(tbSuspectedList); + } else { + for (TBSuspected tbSuspected : tbSuspectedList) { + TBSuspectedDTO dto = modelMapper.map(tbSuspected, TBSuspectedDTO.class); + dto.setUpdateDate(tbSuspected.getLastModDate()); + dto.setUpdatedBy(tbSuspected.getModifiedBy()); + dtos.add(dto); + + if (tbSuspected != null && Boolean.TRUE.equals(tbSuspected.getIsConfirmed())) { + incentiveLogicService.incentiveForTbSuspected( + tbSuspected.getBenId(), + tbSuspected.getVisitDate(), + tbSuspected.getVisitDate(), + tbSuspected.getUserId() + ); + } + } + } + TBSuspectedRequestDTO tbSuspectedRequestDTO = new TBSuspectedRequestDTO(); + tbSuspectedRequestDTO.setTbSuspectedList(dtos); + tbSuspectedRequestDTO.setUserId(request.getAshaId()); + Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy h:mm:ss a").create(); + return gson.toJson(tbSuspectedRequestDTO); + } + + // Stop TB village list (psm + village). Same DTOs and incentive calls as the per-user path, but the + // user's state is read once per user: IncentiveLogicImpl.incentiveForTbSuspected loads the full user + // detail on every call only to check for CG, and does nothing for any other state, so for other + // states the call is skipped and for CG users it is made exactly as before. + private List stopTbVillageDtos(List tbSuspectedList) { + List dtos = new ArrayList<>(); + Map stateByUser = new HashMap<>(); + Set usersWithoutDetail = new HashSet<>(); for (TBSuspected tbSuspected : tbSuspectedList) { - TBSuspectedDTO dto = modelMapper.map(tbSuspected, TBSuspectedDTO.class); + TBSuspectedDTO dto = toDto(tbSuspected); dto.setUpdateDate(tbSuspected.getLastModDate()); dto.setUpdatedBy(tbSuspected.getModifiedBy()); dtos.add(dto); - if (tbSuspected != null && Boolean.TRUE.equals(tbSuspected.getIsConfirmed())) { + if (Boolean.TRUE.equals(tbSuspected.getIsConfirmed())) { + Integer userId = tbSuspected.getUserId(); + if (!stateByUser.containsKey(userId) && !usersWithoutDetail.contains(userId)) { + try { + stateByUser.put(userId, userService.getUserDetail(userId).getStateId()); + } catch (Exception e) { + logger.error("Check Tb Suspected Incentive Exception: ", e); + usersWithoutDetail.add(userId); + } + } + Integer stateCode = stateByUser.get(userId); + if (stateCode != null && stateCode == StateCode.CG.getStateCode()) { + incentiveLogicService.incentiveForTbSuspected( + tbSuspected.getBenId(), + tbSuspected.getVisitDate(), + tbSuspected.getVisitDate(), + userId + ); + } + } + } + return dtos; + } + + // Field-by-field copy of the same-named fields ModelMapper copied (updateDate/updatedBy are set by + // the caller); ModelMapper's reflection per row was most of this list's time at village scale. + private static TBSuspectedDTO toDto(TBSuspected s) { + TBSuspectedDTO dto = new TBSuspectedDTO(); + dto.setId(s.getId()); + dto.setBenId(s.getBenId()); + dto.setVisitLabel(s.getVisitLabel()); + dto.setVisitDate(s.getVisitDate()); + dto.setTypeOfTBCase(s.getTypeOfTBCase()); + dto.setReasonForSuspicion(s.getReasonForSuspicion()); + dto.setIsSputumCollected(s.getIsSputumCollected()); + dto.setSputumSubmittedAt(s.getSputumSubmittedAt()); + dto.setNikshayId(s.getNikshayId()); + dto.setSputumTestResult(s.getSputumTestResult()); + dto.setIsChestXRayDone(s.getIsChestXRayDone()); + dto.setChestXRayResult(s.getChestXRayResult()); + dto.setReasonForRefusalXray(s.getReasonForRefusalXray()); + dto.setReasonForRefusalMTB(s.getReasonForRefusalMTB()); + dto.setReasonForRefusalMDRRIF(s.getReasonForRefusalMDRRIF()); + dto.setReasonForRefusalSputum(s.getReasonForRefusalSputum()); + dto.setMdrRifResult(s.getMdrRifResult()); + dto.setReferralFacility(s.getReferralFacility()); + dto.setIsTBConfirmed(s.getIsTBConfirmed()); + dto.setIsConfirmed(s.getIsConfirmed()); + dto.setIsDRTBConfirmed(s.getIsDRTBConfirmed()); + dto.setReferred(s.getReferred()); + dto.setFollowUps(s.getFollowUps()); + return dto; + } + + @Override + public java.util.Map getRecordsByIds(java.util.Collection ids) { + Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy h:mm:ss a").create(); + java.util.Map out = new java.util.LinkedHashMap<>(); + for (TBSuspected tbSuspected : tbSuspectedRepo.findAllById(ids)) { + TBSuspectedDTO dto = modelMapper.map(tbSuspected, TBSuspectedDTO.class); + dto.setUpdateDate(tbSuspected.getLastModDate()); + dto.setUpdatedBy(tbSuspected.getModifiedBy()); + out.put(tbSuspected.getId(), gson.toJson(dto)); + // Same side effect as getByUserId, so incentives don't depend on which pull the app uses. + if (Boolean.TRUE.equals(tbSuspected.getIsConfirmed())) { incentiveLogicService.incentiveForTbSuspected( tbSuspected.getBenId(), tbSuspected.getVisitDate(), @@ -158,11 +266,7 @@ public String getByUserId(GetBenRequestHandler request) { ); } } - TBSuspectedRequestDTO tbSuspectedRequestDTO = new TBSuspectedRequestDTO(); - tbSuspectedRequestDTO.setTbSuspectedList(dtos); - tbSuspectedRequestDTO.setUserId(request.getAshaId()); - Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy h:mm:ss a").create(); - return gson.toJson(tbSuspectedRequestDTO); + return out; } } diff --git a/src/main/java/com/iemr/flw/service/impl/UserServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/UserServiceImpl.java index 60ae973e7..b3fef7139 100644 --- a/src/main/java/com/iemr/flw/service/impl/UserServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/UserServiceImpl.java @@ -8,6 +8,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.stream.Collectors; + @Service public class UserServiceImpl implements UserService { @@ -21,8 +28,171 @@ public class UserServiceImpl implements UserService { public UserServiceRoleDTO getUserDetail(Integer userId) { logger.info("calling getUserRole for userId: " + userId); - UserServiceRoleDTO userRole = userServiceRoleRepo.getUserRole(userId).get(0); + List roles = userServiceRoleRepo.getUserRole(userId); + if (roles == null || roles.isEmpty()) { + throw new IllegalStateException("No active role mapping for userId " + userId); + } + // A user can have several mapping rows. Taking whichever came first dropped the villages + // on the others; use the STOP-TB (Nikshay) row when there is one, and the villages of + // every row for that same service. + UserServiceRoleDTO userRole = primaryRole(userId, roles); + mergeVillages(userRole, roles); userRole.setFacilityData(facilityDataService.buildFacilityData(userId, userRole.getRoleName())); + + // Two different villages can legitimately share the same name (confirmed live: + // Nikshay village IDs 275135 and 275136 are both named "Sanapindapadar", under + // two different facilities in Boriguma block, Koraput). The mobile app's village + // dropdown displays villageName only, so a user has no way to tell which one + // they're picking - selecting the wrong one loads zero HH records for that + // village, since worklists filter by ID, not name. Append the ID into each name + // segment so duplicates are visually distinguishable. Response shape is + // unchanged - still two parallel comma-separated strings - so this needs zero + // mobile app changes: it just displays whatever text is in the name string + // (confirmed against ServiceLocationViewModel/BindingUtils.setSpinnerItems - + // plain ArrayAdapter, selection is by array position, not by name content). + userRole.setVillageName(appendVillageIds(userRole.getVillageId(), userRole.getVillageName())); + + // Stop TB / Nikshay — additive only. This naturally returns nothing for + // any user whose rows don't have NikshayTUID set, i.e. every non-Stop-TB + // user. Fetched first so the district-by-block patch below can tell + // Stop TB users apart and skip them. + List nikshayRows = userServiceRoleRepo.getNikshayMappingRows(userId, userRole.getProviderServiceMapId()); + boolean isStopTBUser = nikshayRows != null && !nikshayRows.isEmpty(); + + // Stop TB's BlockId holds a Nikshay TU ID, not an AMRIT BlockID - + // joining it against m_districtblock/m_district (what + // getDistrictByBlockId does) would resolve to whatever AMRIT + // district happens to share that same numeric ID by coincidence, + // not real data. Skip this patch for Stop TB users. + if (!isStopTBUser && userRole.getWorkingDistrictId() == null && userRole.getBlockId() != null) { + List districtResults = userServiceRoleRepo.getDistrictByBlockId(userRole.getBlockId()); + if (districtResults != null && !districtResults.isEmpty()) { + Object[] district = districtResults.get(0); + if (district != null && district.length == 2) { + userRole.setWorkingDistrictId(((Number) district[0]).intValue()); + userRole.setWorkingDistrictName((String) district[1]); + } + } + } + + if (isStopTBUser) { + TreeSet tuIds = new TreeSet<>(); + TreeSet facilityIds = new TreeSet<>(); + Integer districtId = null; + for (Object[] row : nikshayRows) { + addCsvIds((String) row[0], tuIds); + addCsvIds((String) row[1], facilityIds); + if (districtId == null && row[2] != null) { + districtId = ((Number) row[2]).intValue(); + } + } + + Map tuNames = tuIds.isEmpty() ? Map.of() + : toIdNameMap(userServiceRoleRepo.findNikshayTuNames(tuIds)); + Map facilityNames = facilityIds.isEmpty() ? Map.of() + : toIdNameMap(userServiceRoleRepo.findNikshayFacilityNames(facilityIds)); + + userRole.setTuId(joinIds(tuIds)); + userRole.setTuName(joinNames(tuIds, tuNames)); + userRole.setHealthFacilityId(joinIds(facilityIds)); + userRole.setHealthFacilityName(joinNames(facilityIds, facilityNames)); + + // Stop TB never sets WorkingLocationID, so workingDistrictId/Name + // (derived from it) are always null. DistrictID sits directly on + // the mapping row instead - Stop TB has no "work location" + // indirection layer the way other servicelines do, so read it + // straight from there rather than trying to derive it. + if (districtId != null) { + userRole.setWorkingDistrictId(districtId); + userRole.setWorkingDistrictName(userServiceRoleRepo.findNikshayDistrictName(districtId)); + } + } + return userRole; } + + private UserServiceRoleDTO primaryRole(Integer userId, List roles) { + if (roles.size() == 1) return roles.get(0); + for (UserServiceRoleDTO role : roles) { + List nikshay = userServiceRoleRepo.getNikshayMappingRows(userId, role.getProviderServiceMapId()); + if (nikshay != null && !nikshay.isEmpty()) return role; + } + return roles.get(0); + } + + // Unions the parallel villageId/villageName lists of every row for the chosen row's service, + // keeping first-seen order and one entry per village ID. Rows whose two lists don't line up + // are left out of the merge rather than risk pairing an ID with the wrong name. + private void mergeVillages(UserServiceRoleDTO userRole, List roles) { + java.util.LinkedHashMap byId = new java.util.LinkedHashMap<>(); + for (UserServiceRoleDTO role : roles) { + if (!java.util.Objects.equals(role.getProviderServiceMapId(), userRole.getProviderServiceMapId())) continue; + if (role.getVillageId() == null || role.getVillageId().isBlank() + || role.getVillageName() == null || role.getVillageName().isBlank()) continue; + String[] ids = role.getVillageId().split(",", -1); + String[] names = role.getVillageName().split(",", -1); + if (ids.length != names.length) continue; + for (int i = 0; i < ids.length; i++) { + if (!ids[i].isBlank()) byId.putIfAbsent(ids[i].trim(), names[i].trim()); + } + } + if (byId.isEmpty()) return; + userRole.setVillageId(String.join(",", byId.keySet())); + userRole.setVillageName(String.join(",", byId.values())); + } + + // Zips the parallel villageId/villageName comma lists and rejoins each pair as + // "name (id)", e.g. "Sanapindapadar,Sanapindapadar" + "275135,275136" becomes + // "Sanapindapadar (275135),Sanapindapadar (275136)". Falls back to the original, + // unmodified villageName on any shape mismatch (null/blank, or unequal segment + // counts between the two lists) rather than risk emitting a malformed string. + private String appendVillageIds(String villageId, String villageName) { + if (villageId == null || villageId.isBlank() || villageName == null || villageName.isBlank()) { + return villageName; + } + String[] ids = villageId.split(",", -1); + String[] names = villageName.split(",", -1); + if (ids.length != names.length) { + logger.warn("villageId/villageName segment count mismatch ({} vs {}); leaving villageName unmodified", + ids.length, names.length); + return villageName; + } + StringBuilder result = new StringBuilder(); + for (int i = 0; i < names.length; i++) { + if (i > 0) { + result.append(","); + } + result.append(names[i].trim()).append(" (").append(ids[i].trim()).append(")"); + } + return result.toString(); + } + + private void addCsvIds(String csv, Collection target) { + if (csv == null || csv.isBlank()) { + return; + } + for (String id : csv.split(",")) { + try { + target.add(Integer.valueOf(id.trim())); + } catch (NumberFormatException ignored) { + // skip malformed entries rather than fail the whole login + } + } + } + + private Map toIdNameMap(List rows) { + Map map = new HashMap<>(); + for (Object[] row : rows) { + map.put(((Number) row[0]).intValue(), (String) row[1]); + } + return map; + } + + private String joinIds(Collection ids) { + return ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + } + + private String joinNames(Collection ids, Map names) { + return ids.stream().map(id -> names.getOrDefault(id, "")).collect(Collectors.joining(",")); + } } diff --git a/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java b/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java new file mode 100644 index 000000000..b34acf9a8 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/WorklistPagePrefetch.java @@ -0,0 +1,191 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.BenAnthropometryDetail; +import com.iemr.flw.domain.iemr.BenFlowStatus; +import com.iemr.flw.domain.iemr.BenPhysicalVitalDetail; +import com.iemr.flw.domain.identity.*; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Loads everything the worklist builder needs for one page with one query per table, instead of + * ~16 queries per beneficiary. Each query keeps the ordering of the per-row query it replaces, so + * the response is unchanged. + * + * IDs are written into the SQL as numeric literals rather than bound as a collection: Hibernate 6.4.1 + * in this service throws "Binding is multi-valued" for every IN (:list) parameter. The values are + * Long/BigInteger read from the database, so the literal list cannot carry anything but digits. + */ +@Component +public class WorklistPagePrefetch { + + @PersistenceContext(unitName = "db_identity") + private EntityManager identityEm; + + @PersistenceContext(unitName = "db_iemr") + private EntityManager iemrEm; + + public static class Page { + final Map> mappingsByAddress = new HashMap<>(); + final Map details = new HashMap<>(); + final Map accounts = new HashMap<>(); + final Map images = new HashMap<>(); + final Map addresses = new HashMap<>(); + final Map contacts = new HashMap<>(); + final Map> benIdsByRegId = new HashMap<>(); + final Map> rmnchByRegId = new HashMap<>(); + final Map> bornBirthByRegId = new HashMap<>(); + final Map> householdsById = new HashMap<>(); + final Map> flowsByRegId = new HashMap<>(); + final Map> anthropometryByRegId = new HashMap<>(); + final Map> vitalsByRegId = new HashMap<>(); + final Set regIdsWithHealthId = new HashSet<>(); + final Map userIdByName = new HashMap<>(); + + public List mappings(BigInteger addressId) { + return mappingsByAddress.getOrDefault(addressId, Collections.emptyList()); + } + public RMNCHMBeneficiarydetail detail(BigInteger id) { return details.get(id); } + public RMNCHMBeneficiaryAccount account(BigInteger id) { return accounts.get(id); } + public RMNCHMBeneficiaryImage image(BigInteger id) { return images.get(id); } + public RMNCHMBeneficiaryaddress address(BigInteger id) { return addresses.get(id); } + public RMNCHMBeneficiarycontact contact(BigInteger id) { return contacts.get(id); } + public List benIds(Long regId) { return benIdsByRegId.getOrDefault(regId, Collections.emptyList()); } + public List rmnch(Long regId) { return rmnchByRegId.getOrDefault(regId, Collections.emptyList()); } + public List bornBirth(Long regId) { return bornBirthByRegId.getOrDefault(regId, Collections.emptyList()); } + public List households(Long householdId) { return householdsById.getOrDefault(householdId, Collections.emptyList()); } + public List flows(Long regId) { return flowsByRegId.getOrDefault(regId, Collections.emptyList()); } + public List anthropometry(Long regId) { return anthropometryByRegId.getOrDefault(regId, Collections.emptyList()); } + public List vitals(Long regId) { return vitalsByRegId.getOrDefault(regId, Collections.emptyList()); } + public boolean hasHealthId(Long regId) { return regIdsWithHealthId.contains(regId); } + public Map userIdCache() { return userIdByName; } + } + + /** The page's addresses in the order of {@code ids} (the worklist page order); ids with no row are skipped, as before. */ + public List addressesInOrder(List ids) { + Map byId = new HashMap<>(); + putAll(byId, entities(identityEm, RMNCHMBeneficiaryaddress.class, + "SELECT * FROM db_identity.i_beneficiaryaddress WHERE BenAddressID IN (%s)", + new LinkedHashSet<>(ids)), RMNCHMBeneficiaryaddress::getBenAddressID); + return ids.stream().map(byId::get).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public Page load(List pageAddresses) { + Page page = new Page(); + pageAddresses.forEach(a -> page.addresses.put(a.getBenAddressID(), a)); + // The builder looks mappings up by address.getId(), which is the VanSerialNo column, not the + // address primary key - keep that exactly. + Set lookupIds = pageAddresses.stream().map(RMNCHMBeneficiaryaddress::getId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + if (lookupIds.isEmpty()) return page; + + // getByAddressID: ORDER BY BenMapId DESC + for (RMNCHMBeneficiarymapping m : entities(identityEm, RMNCHMBeneficiarymapping.class, + "SELECT * FROM db_identity.i_beneficiarymapping WHERE BenAddressId IN (%s) ORDER BY BenMapId DESC", lookupIds)) { + page.mappingsByAddress.computeIfAbsent(m.getBenAddressId(), k -> new ArrayList<>()).add(m); + } + // the builder only uses the first (newest) mapping of each address + List used = page.mappingsByAddress.values().stream() + .map(l -> l.get(0)).collect(Collectors.toList()); + + putAll(page.details, entities(identityEm, RMNCHMBeneficiarydetail.class, + "SELECT * FROM db_identity.i_beneficiarydetails WHERE BeneficiaryDetailsId IN (%s)", + ids(used, RMNCHMBeneficiarymapping::getBenDetailsId)), RMNCHMBeneficiarydetail::getBeneficiaryDetailsId); + putAll(page.accounts, entities(identityEm, RMNCHMBeneficiaryAccount.class, + "SELECT * FROM db_identity.i_beneficiaryaccount WHERE BenAccountID IN (%s)", + ids(used, RMNCHMBeneficiarymapping::getBenAccountID)), RMNCHMBeneficiaryAccount::getBenAccountID); + putAll(page.images, entities(identityEm, RMNCHMBeneficiaryImage.class, + "SELECT * FROM db_identity.i_beneficiaryimage WHERE BenImageId IN (%s)", + ids(used, RMNCHMBeneficiarymapping::getBenImageId)), RMNCHMBeneficiaryImage::getBenImageId); + putAll(page.contacts, entities(identityEm, RMNCHMBeneficiarycontact.class, + "SELECT * FROM db_identity.i_beneficiarycontacts WHERE BenContactsID IN (%s)", + ids(used, RMNCHMBeneficiarymapping::getBenContactsId)), RMNCHMBeneficiarycontact::getBenContactsID); + Set missingAddressIds = ids(used, RMNCHMBeneficiarymapping::getBenAddressId); + missingAddressIds.removeAll(page.addresses.keySet()); + putAll(page.addresses, entities(identityEm, RMNCHMBeneficiaryaddress.class, + "SELECT * FROM db_identity.i_beneficiaryaddress WHERE BenAddressID IN (%s)", missingAddressIds), + RMNCHMBeneficiaryaddress::getBenAddressID); + + Set regIds = ids(used, RMNCHMBeneficiarymapping::getBenRegId); + if (regIds.isEmpty()) return page; + + for (Object[] r : rows(identityEm, + "SELECT BenRegId, BeneficiaryID FROM db_identity.m_beneficiaryregidmapping WHERE BenRegId IN (%s)", regIds)) { + page.benIdsByRegId.computeIfAbsent(toLong(r[0]), k -> new ArrayList<>()).add(toBigInteger(r[1])); + } + // getDetailsByRegID: ORDER BY beneficiaryDetails_RmnchId DESC + group(page.rmnchByRegId, entities(identityEm, RMNCHBeneficiaryDetailsRmnch.class, + "SELECT * FROM db_identity.i_beneficiarydetails_rmnch WHERE BeneficiaryRegID IN (%s) ORDER BY beneficiaryDetails_RmnchId DESC", + regIds), RMNCHBeneficiaryDetailsRmnch::getBenRegId); + group(page.bornBirthByRegId, entities(identityEm, RMNCHBornBirthDetails.class, + "SELECT * FROM db_identity.i_bornbirthdeatils WHERE BeneficiaryRegID IN (%s) ORDER BY BornBirthDeatilsId", + regIds), RMNCHBornBirthDetails::getBenRegId); + + // households of the newest rmnch row (the one the builder uses) + Set householdIds = page.rmnchByRegId.values().stream().map(l -> l.get(0).getHouseoldId()) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + group(page.householdsById, entities(identityEm, RMNCHHouseHoldDetails.class, + "SELECT * FROM db_identity.i_householddetails WHERE houseoldId IN (%s) ORDER BY houseHoldDetailsId", + householdIds), RMNCHHouseHoldDetails::getHouseoldId); + + group(page.flowsByRegId, entities(iemrEm, BenFlowStatus.class, + "SELECT * FROM db_iemr.i_ben_flow_outreach WHERE beneficiary_reg_id IN (%s) ORDER BY ben_flow_id", + regIds), BenFlowStatus::getBeneficiaryRegID); + // findByBeneficiaryRegIDOrderByCreatedDateDesc + group(page.anthropometryByRegId, entities(iemrEm, BenAnthropometryDetail.class, + "SELECT * FROM db_iemr.t_phy_anthropometry WHERE BeneficiaryRegID IN (%s) ORDER BY CreatedDate DESC", + regIds), BenAnthropometryDetail::getBeneficiaryRegID); + group(page.vitalsByRegId, entities(iemrEm, BenPhysicalVitalDetail.class, + "SELECT * FROM db_iemr.t_phy_vitals WHERE BeneficiaryRegID IN (%s) ORDER BY CreatedDate DESC", + regIds), BenPhysicalVitalDetail::getBeneficiaryRegID); + + for (Object id : iemrEm.createNativeQuery(String.format( + "SELECT DISTINCT BeneficiaryRegID FROM db_iemr.m_benhealthidmapping WHERE BeneficiaryRegID IN (%s)", + literalList(regIds))).getResultList()) { + page.regIdsWithHealthId.add(toLong(id)); + } + return page; + } + + @SuppressWarnings("unchecked") + private static List entities(EntityManager em, Class type, String sql, Collection ids) { + if (ids.isEmpty()) return Collections.emptyList(); + return em.createNativeQuery(String.format(sql, literalList(ids)), type).getResultList(); + } + + @SuppressWarnings("unchecked") + private static List rows(EntityManager em, String sql, Collection ids) { + if (ids.isEmpty()) return Collections.emptyList(); + return em.createNativeQuery(String.format(sql, literalList(ids))).getResultList(); + } + + private static Set ids(List rows, Function getter) { + return rows.stream().map(getter).filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private static void putAll(Map target, List rows, Function key) { + rows.forEach(r -> target.putIfAbsent(key.apply(r), r)); + } + + private static void group(Map> target, List rows, Function key) { + rows.forEach(r -> target.computeIfAbsent(key.apply(r), k -> new ArrayList<>()).add(r)); + } + + // Only whole numbers ever reach the SQL text: anything else is rejected rather than quoted. + private static String literalList(Collection ids) { + return ids.stream().map(id -> { + if (id instanceof Number) return new BigInteger(id.toString()).toString(); + throw new IllegalArgumentException("Non-numeric id in worklist prefetch: " + id); + }).collect(Collectors.joining(",")); + } + + private static Long toLong(Object o) { return o == null ? null : ((Number) o).longValue(); } + + private static BigInteger toBigInteger(Object o) { return o == null ? null : new BigInteger(o.toString()); } +} diff --git a/src/main/java/com/iemr/flw/service/sync/StopTBSyncType.java b/src/main/java/com/iemr/flw/service/sync/StopTBSyncType.java new file mode 100644 index 000000000..8ac1db509 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/sync/StopTBSyncType.java @@ -0,0 +1,151 @@ +package com.iemr.flw.service.sync; + +import java.util.List; + +/** + * The record types the STOP-TB changes feed (/stoptb/sync/changes) serves, in snapshot order + * (beneficiaries first, so a tablet has the person before their TB records). + * + * For each type: + * - {@code logEntity}: the entity name the hub's change-log triggers write (AMRIT-DB + * dbiemr/V132__Sync_Change_Log.sql and dbidentity/V32__Sync_Change_Log_Beneficiary.sql); + * - {@code snapshotKeySql}: the keys in scope, for the first full download, with the same filters + * the type's getAll endpoint applies. Parameters: :psm, :villages, :after, :limit; + * - {@code sourceTables}: every table the type's record is built from. Each must have the change-log + * triggers, or edits to it would never reach tablets. The feed checks this on the hub + * ({@code StopTBSyncServiceImpl#isSupported}) and a unit test checks every mapped table is either + * here or deliberately not synced. + * + * @author Piramal Swasthya + */ +public enum StopTBSyncType { + + BENEFICIARY("beneficiary", "beneficiary", + "SELECT DISTINCT f.beneficiary_reg_id FROM db_iemr.i_ben_flow_outreach f " + + "WHERE f.providerServiceMapID = :psm AND f.villageID IN (:villages) AND f.deleted = 0 " + + "AND f.beneficiary_reg_id > :after ORDER BY f.beneficiary_reg_id LIMIT :limit", + List.of("db_identity.i_beneficiarymapping", "db_identity.i_beneficiarydetails", + "db_identity.i_beneficiarydetails_rmnch", "db_identity.i_bornbirthdeatils", + "db_identity.i_beneficiaryaddress", "db_identity.i_beneficiarycontacts", + "db_identity.i_beneficiaryimage", "db_identity.i_beneficiaryaccount", + "db_identity.i_householddetails", "db_iemr.t_phy_anthropometry", "db_iemr.t_phy_vitals", + "db_iemr.i_ben_flow_outreach")), + + TB_SCREENING("tbScreening", "tb_screening", + "SELECT s.id FROM db_iemr.tb_screening s WHERE (s.deleted = 0 OR s.deleted IS NULL) " + + "AND s.ben_reg_id IN (" + Scope.REG + ") AND s.id > :after ORDER BY s.id LIMIT :limit", + List.of("db_iemr.tb_screening")), + + GENERAL_EXAMINATION("generalExamination", "general_examination", + "SELECT e.id FROM db_iemr.tb_stoptb_general_examination e WHERE e.deleted = 0 " + + "AND e.beneficiary_reg_id IN (" + Scope.REG + ") AND e.id > :after ORDER BY e.id LIMIT :limit", + List.of("db_iemr.tb_stoptb_general_examination")), + + /** OPD visits saved to the standard tables, keyed by BenVisitID. */ + GENERAL_OPD("generalOpd", "opd_visit", + "SELECT v.BenVisitID FROM db_iemr.t_benvisitdetail v WHERE v.Deleted = 0 AND v.ProviderServiceMapID = :psm " + + "AND v.BeneficiaryRegID IN (" + Scope.REG + ") AND v.BenVisitID > :after ORDER BY v.BenVisitID LIMIT :limit", + // t_prescription holds the visit's note (latest prescription instruction) + List.of("db_iemr.t_benvisitdetail", "db_iemr.t_benchiefcomplaint", "db_iemr.t_prescribeddrug", + "db_iemr.t_prescription")), + + /** Older free-text OPD rows; same record shape as GENERAL_OPD. */ + GENERAL_OPD_LEGACY("generalOpdLegacy", "general_opd", + "SELECT o.id FROM db_iemr.tb_stoptb_general_opd o WHERE o.deleted = 0 " + + "AND o.ben_reg_id IN (" + Scope.REG + ") AND o.id > :after ORDER BY o.id LIMIT :limit", + List.of("db_iemr.tb_stoptb_general_opd")), + + DIAGNOSTICS("diagnostics", "diagnostics", + "SELECT d.id FROM db_iemr.tb_stoptb_diagnostics d WHERE d.deleted = 0 " + + "AND d.ben_reg_id IN (" + Scope.REG + ") AND d.id > :after ORDER BY d.id LIMIT :limit", + List.of("db_iemr.tb_stoptb_diagnostics")), + + TB_SUSPECTED("tbSuspected", "tb_suspected", + "SELECT t.id FROM db_iemr.tb_suspected t WHERE t.ben_id IN (" + Scope.BEN_ID + ") " + + "AND t.id > :after ORDER BY t.id LIMIT :limit", + List.of("db_iemr.tb_suspected")), + + TB_CONFIRMED("tbConfirmed", "tb_confirmed", + "SELECT c.id FROM db_iemr.tb_confirmed_cases c WHERE c.ben_id IN (" + Scope.BEN_ID + ") " + + "AND c.id > :after ORDER BY c.id LIMIT :limit", + List.of("db_iemr.tb_confirmed_cases")), + + FORM_RESPONSE("formResponse", "form_response", + "SELECT r.responseId FROM db_iemr.t_form_response r WHERE r.beneficiaryId IN (" + Scope.BEN_ID_NOT_DELETED + ") " + + "AND r.responseId > :after ORDER BY r.responseId LIMIT :limit", + List.of("db_iemr.t_form_response", "db_iemr.t_section_response", "db_iemr.t_question_response")), + + DIAGNOSTIC_ORDER("diagnosticOrder", "diagnostic_order", + "SELECT o.id FROM db_iemr.tb_diagnostic_order o WHERE o.deleted = 0 " + + "AND o.beneficiary_id IN (" + Scope.BEN_ID_NOT_DELETED + ") AND o.id > :after ORDER BY o.id LIMIT :limit", + List.of("db_iemr.tb_diagnostic_order", "db_iemr.tb_diagnostic_result")); + + /** Beneficiaries in the requested villages, the same way today's getAll endpoints scope. */ + static final class Scope { + static final String REG = "SELECT f.beneficiary_reg_id FROM db_iemr.i_ben_flow_outreach f " + + "WHERE f.providerServiceMapID = :psm AND f.villageID IN (:villages)"; + static final String BEN_ID = "SELECT f.beneficiary_id FROM db_iemr.i_ben_flow_outreach f " + + "WHERE f.providerServiceMapID = :psm AND f.villageID IN (:villages)"; + static final String BEN_ID_NOT_DELETED = BEN_ID + " AND f.deleted = 0"; + // The same, for specific beneficiaries (by BenRegId) that just came into scope. + static final String REG_FOR_BENS = "SELECT f.beneficiary_reg_id FROM db_iemr.i_ben_flow_outreach f " + + "WHERE f.beneficiary_reg_id IN (:regIds)"; + static final String BEN_ID_FOR_BENS = "SELECT f.beneficiary_id FROM db_iemr.i_ben_flow_outreach f " + + "WHERE f.beneficiary_reg_id IN (:regIds)"; + + private Scope() { + } + } + + private final String apiName; + private final String logEntity; + private final String snapshotKeySql; + private final String keysForBeneficiariesSql; + private final List sourceTables; + + StopTBSyncType(String apiName, String logEntity, String snapshotKeySql, List sourceTables) { + this.apiName = apiName; + this.logEntity = logEntity; + this.snapshotKeySql = snapshotKeySql; + // The snapshot query with its village scope swapped for "these beneficiaries" and its keyset + // paging dropped. Parameters: :regIds (and :psm where the snapshot filters on it). + this.keysForBeneficiariesSql = snapshotKeySql + .replace(Scope.BEN_ID_NOT_DELETED, Scope.BEN_ID_FOR_BENS) + .replace(Scope.BEN_ID, Scope.BEN_ID_FOR_BENS) + .replace(Scope.REG, Scope.REG_FOR_BENS) + .replaceAll(" AND \\S+ > :after ORDER BY \\S+ LIMIT :limit$", ""); + this.sourceTables = sourceTables; + } + + /** The "type" field of an item in the feed. */ + public String apiName() { + return apiName; + } + + public String logEntity() { + return logEntity; + } + + public String snapshotKeySql() { + return snapshotKeySql; + } + + /** + * Keys of this type's records for beneficiaries that just came into a tablet's scope, so their + * older records (logged before they were in scope) still reach it. Not used for BENEFICIARY. + */ + public String keysForBeneficiariesSql() { + return keysForBeneficiariesSql; + } + + public List sourceTables() { + return sourceTables; + } + + public static StopTBSyncType fromLogEntity(String entity) { + for (StopTBSyncType t : values()) { + if (t.logEntity.equals(entity)) return t; + } + return null; + } +} diff --git a/src/main/java/com/iemr/flw/service/sync/SyncToken.java b/src/main/java/com/iemr/flw/service/sync/SyncToken.java new file mode 100644 index 000000000..0055b0247 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/sync/SyncToken.java @@ -0,0 +1,117 @@ +package com.iemr.flw.service.sync; + +/** + * The tablet's opaque sync position. Two kinds: + * + * d.<serverId>.<seq>.<issuedEpochSeconds>.<typesMask>.<scopeHash> + * changes after change-log line seq on this hub; + * s.<serverId>.<snapshotSeq>.<typeIndex>.<afterKey>.<typesMask>.<scopeHash> + * part-way through a first download: the next page starts at type typeIndex, after afterKey, + * and when it finishes the tablet continues with changes after snapshotSeq. + * + * serverId is the hub's MySQL server_uuid, so a token from another hub is never trusted. The issue + * time lets the hub tell when the log lines a token points after may have been cleaned up. + * typesMask is the set of record types the token was issued for (bit = StopTBSyncType ordinal): a + * token only covers those types, so a request for a different set starts a new snapshot. scopeHash + * is the service and village list the token was issued for (UserSyncScope.scopeHash); a different + * scope also starts a new snapshot. + * + * @author Piramal Swasthya + */ +public final class SyncToken { + + public enum Kind { DELTA, SNAPSHOT } + + private final Kind kind; + private final String serverId; + private final long seq; + private final long issuedAt; + private final int typeIndex; + private final long afterKey; + private final long typesMask; + private final long scopeHash; + + private SyncToken(Kind kind, String serverId, long seq, long issuedAt, int typeIndex, long afterKey, + long typesMask, long scopeHash) { + this.kind = kind; + this.serverId = serverId; + this.seq = seq; + this.issuedAt = issuedAt; + this.typeIndex = typeIndex; + this.afterKey = afterKey; + this.typesMask = typesMask; + this.scopeHash = scopeHash; + } + + public static SyncToken delta(String serverId, long seq, long issuedAt, long typesMask, long scopeHash) { + return new SyncToken(Kind.DELTA, serverId, seq, issuedAt, 0, 0, typesMask, scopeHash); + } + + public static SyncToken snapshot(String serverId, long snapshotSeq, int typeIndex, long afterKey, long typesMask, + long scopeHash) { + return new SyncToken(Kind.SNAPSHOT, serverId, snapshotSeq, 0, typeIndex, afterKey, typesMask, scopeHash); + } + + /** null for a missing or unreadable token; the caller then starts a snapshot. */ + public static SyncToken parse(String token) { + if (token == null || token.isBlank()) return null; + String[] p = token.trim().split("\\."); + try { + if (p.length == 6 && "d".equals(p[0]) && !p[1].isEmpty()) { + return delta(p[1], Long.parseLong(p[2]), Long.parseLong(p[3]), Long.parseLong(p[4]), + Long.parseLong(p[5])); + } + if (p.length == 7 && "s".equals(p[0]) && !p[1].isEmpty()) { + return snapshot(p[1], Long.parseLong(p[2]), Integer.parseInt(p[3]), Long.parseLong(p[4]), + Long.parseLong(p[5]), Long.parseLong(p[6])); + } + } catch (NumberFormatException e) { + return null; + } + return null; + } + + public String encode() { + return kind == Kind.DELTA + ? "d." + serverId + "." + seq + "." + issuedAt + "." + typesMask + "." + scopeHash + : "s." + serverId + "." + seq + "." + typeIndex + "." + afterKey + "." + typesMask + "." + scopeHash; + } + + public Kind kind() { + return kind; + } + + public String serverId() { + return serverId; + } + + /** DELTA: last change-log line applied. SNAPSHOT: the line changes continue from afterwards. */ + public long seq() { + return seq; + } + + public long issuedAt() { + return issuedAt; + } + + public int typeIndex() { + return typeIndex; + } + + public long afterKey() { + return afterKey; + } + + public long typesMask() { + return typesMask; + } + + public long scopeHash() { + return scopeHash; + } + + @Override + public String toString() { + return encode(); + } +} diff --git a/src/main/java/com/iemr/flw/service/sync/UserSyncScope.java b/src/main/java/com/iemr/flw/service/sync/UserSyncScope.java new file mode 100644 index 000000000..0573daf04 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/sync/UserSyncScope.java @@ -0,0 +1,66 @@ +package com.iemr.flw.service.sync; + +import java.util.*; + +/** + * The villages a user may sync, per provider service map: every active role-mapping row counts + * (a user can have several), each holding a comma-separated village list. + * + * @author Piramal Swasthya + */ +public final class UserSyncScope { + + private final Map> villagesByService; + + private UserSyncScope(Map> villagesByService) { + this.villagesByService = villagesByService; + } + + /** rows: [ProviderServiceMapID, Villageid csv] from m_userservicerolemapping. */ + public static UserSyncScope fromMappingRows(List rows) { + Map> out = new HashMap<>(); + for (Object[] row : rows) { + if (row == null || row[0] == null) continue; + Set villages = out.computeIfAbsent(((Number) row[0]).intValue(), k -> new TreeSet<>()); + if (row.length > 1 && row[1] != null) { + for (String id : row[1].toString().split(",")) { + try { + villages.add(Integer.valueOf(id.trim())); + } catch (NumberFormatException ignored) { + // malformed entry: not a village the user can sync + } + } + } + } + return new UserSyncScope(out); + } + + /** + * The villages to sync for this request: all of the user's villages for the service when none + * are asked for, otherwise exactly the ones asked for. Throws when the service or any village + * isn't mapped to the user. + */ + public List resolve(Integer providerServiceMapID, List requested) { + Set allowed = villagesByService.get(providerServiceMapID); + if (allowed == null) { + throw new IllegalArgumentException("User is not mapped to providerServiceMapID " + providerServiceMapID); + } + if (requested == null || requested.isEmpty()) { + if (allowed.isEmpty()) throw new IllegalArgumentException("User has no villages for this service"); + return new ArrayList<>(allowed); + } + List outside = new ArrayList<>(); + for (Integer v : requested) if (!allowed.contains(v)) outside.add(v); + if (!outside.isEmpty()) { + throw new IllegalArgumentException("Villages not assigned to the user: " + outside); + } + return new ArrayList<>(new TreeSet<>(requested)); + } + + /** A stable fingerprint of a service and its village list, bound into sync tokens. */ + public static long scopeHash(Integer providerServiceMapID, List villages) { + List sorted = new ArrayList<>(villages); + Collections.sort(sorted); + return Integer.toUnsignedLong((providerServiceMapID + "|" + sorted).hashCode()); + } +} diff --git a/src/main/java/com/iemr/flw/utils/ApiTimingFilter.java b/src/main/java/com/iemr/flw/utils/ApiTimingFilter.java new file mode 100644 index 000000000..d93159634 --- /dev/null +++ b/src/main/java/com/iemr/flw/utils/ApiTimingFilter.java @@ -0,0 +1,40 @@ +package com.iemr.flw.utils; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +// One [API-TIMING] line per request (method, path, status, total ms incl. auth filters) so camp-laptop +// logs show every API's time: journalctl -u amrit-flw-api | grep API-TIMING +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class ApiTimingFilter implements Filter { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + long start = System.currentTimeMillis(); + try { + chain.doFilter(servletRequest, servletResponse); + } finally { + logger.info("[API-TIMING] {} {} status={} ms={}", request.getMethod(), request.getRequestURI(), + response.getStatus(), System.currentTimeMillis() - start); + } + } +} diff --git a/src/main/java/com/iemr/flw/utils/response/OutputResponse.java b/src/main/java/com/iemr/flw/utils/response/OutputResponse.java index d4cb36705..7b5f7f8e3 100644 --- a/src/main/java/com/iemr/flw/utils/response/OutputResponse.java +++ b/src/main/java/com/iemr/flw/utils/response/OutputResponse.java @@ -83,6 +83,15 @@ public void setResponse(String message) { } + // Same result as setResponse(gson.toJson(x)) for an object/array, without writing the JSON text + // and parsing it back: pass gson.toJsonTree(x). + public void setResponse(JsonElement tree) { + this.data = tree; + statusCode = SUCCESS; + errorMessage = "Success"; + status = "Success"; + } + public void setError(Throwable thrown) { Date currDate = Calendar.getInstance().getTime(); logger.info("error happened due to " + thrown.getClass().getSimpleName() + " at " + currDate.toString()); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2ffd6d6b8..e03faaa00 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -35,7 +35,7 @@ spring.datasource.tomcat.remove-abandoned=true spring.datasource.tomcat.logAbandoned=true spring.datasource.continue-on-error=true spring.datasource.tomcat.remove-abandoned-timeout=600 -door-to-door-page-size=10 +door-to-door-page-size=50 #Get-HRP-Status get-HRP-Status=ANC/getHRPStatus diff --git a/src/test/java/com/iemr/flw/service/sync/StopTBSyncCoverageTest.java b/src/test/java/com/iemr/flw/service/sync/StopTBSyncCoverageTest.java new file mode 100644 index 000000000..dea113d81 --- /dev/null +++ b/src/test/java/com/iemr/flw/service/sync/StopTBSyncCoverageTest.java @@ -0,0 +1,106 @@ +package com.iemr.flw.service.sync; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Every table FLW-API maps must be either synced to STOP-TB tablets through the change log + * (listed in a {@link StopTBSyncType}'s sourceTables, which the hub checks for triggers) or listed + * below as deliberately not synced. A new entity fails this test until someone decides which, so a + * STOP-TB table can't be added without its change-log triggers. + */ +class StopTBSyncCoverageTest { + + /** STOP-TB-adjacent tables that the app does not pull, with the reason. */ + static final Map NOT_SYNCED_STOPTB = Map.ofEntries( + Map.entry("db_iemr.t_phy_generalexam", "copy of the general examination for the standard tables; the app pulls tb_stoptb_general_examination"), + Map.entry("db_iemr.t_benreferdetails", "copy written on save for the standard tables; not read by any STOP-TB pull"), + Map.entry("db_iemr.tb_stoptb_registration", "not read by any service"), + Map.entry("db_iemr.tb_diagnostic_document", "the app does not pull documents"), + Map.entry("db_iemr.tb_diagnostic_provider_token", "vendor credentials, server only"), + Map.entry("db_identity.m_beneficiaryregidmapping", "BenRegId/BeneficiaryID pool, fixed once assigned"), + Map.entry("db_iemr.t_dynamic_form", "form definitions: master data, pulled separately"), + Map.entry("db_iemr.t_form_version", "form definitions: master data, pulled separately"), + Map.entry("db_iemr.t_form_section", "form definitions: master data, pulled separately"), + Map.entry("db_iemr.t_section_question", "form definitions: master data, pulled separately"), + Map.entry("db_iemr.t_question_option", "form definitions: master data, pulled separately"), + Map.entry("db_iemr.t_question_validation", "form definitions: master data, pulled separately"), + Map.entry("db_iemr.t_option_condition", "form definitions: master data, pulled separately"), + Map.entry("db_iemr.t_dynamic_form_migration_log", "server-side bookkeeping"), + Map.entry("db_iemr.m_user", "user master, read at login"), + Map.entry("db_iemr.v_userservicerolemapping", "view of user roles, read at login"), + Map.entry("db_iemr.general_opd_entry", "not part of the STOP-TB pull")); + + /** Tables of other FLW programmes (ASHA app), not used by STOP-TB. */ + static final Set OTHER_PROGRAMMES = Set.of( + "db_iemr.adolescent_health", "db_iemr.ahd_form", "db_iemr.anc_counselling_care", "db_iemr.asha_profile", + "db_iemr.asha_supervisor_mapping", "db_iemr.campaign_filariasis_mda", "db_iemr.campaign_ors", + "db_iemr.campaign_pulse_polio", "db_iemr.cdtf_visit_details", "db_iemr.deworming_form", + "db_iemr.eligible_couple_tracking", "db_iemr.high_risk_assess", "db_iemr.i_mobilization_mosquito_net", + "db_iemr.incentive_activity_record", "db_iemr.incentive_pending_activity", "db_iemr.irs_round", + "db_iemr.leprosy_follow_up", "db_iemr.m_immunizationservicevaccination", "db_iemr.m_incentive_activity", + "db_iemr.m_incentive_activity_lang_mapping", "db_iemr.m_otp_beneficiary", "db_iemr.m_smstemplate", + "db_iemr.maa_meeting", "db_iemr.malaria_follow_up", "db_iemr.non_pregnant_high_risk_assess", + "db_iemr.non_pregnant_high_risk_track", "db_iemr.phc_review_meeting", "db_iemr.pregnant_high_risk_assess", + "db_iemr.pregnant_high_risk_track", "db_iemr.sammelan_attachment", "db_iemr.sammelan_record", "db_iemr.screening_aesje", + "db_iemr.screening_filaria", "db_iemr.screening_kala_azar", "db_iemr.screening_leprosy", + "db_iemr.screening_malaria", "db_iemr.t_anc_visit", "db_iemr.t_anccare", "db_iemr.t_cbacdetails", + "db_iemr.t_cdr", "db_iemr.t_child_register", "db_iemr.t_childvaccinedetail1", "db_iemr.t_delivery_outcome", + "db_iemr.t_eligible_couple_register", "db_iemr.t_eye_checkup", "db_iemr.t_hbnc_part1", "db_iemr.t_hbnc_part2", + "db_iemr.t_hbnc_visit", "db_iemr.t_hbnc_visit_card", "db_iemr.t_hbyc", "db_iemr.t_hbyc_child_visits", + "db_iemr.t_ifa_distribution", "db_iemr.t_ifa_distribution_data", "db_iemr.t_infant_register", + "db_iemr.t_mda_distribution_data", "db_iemr.t_mdsr", "db_iemr.t_micro_birth_plan", "db_iemr.t_ors_distribution", + "db_iemr.t_pmsma", "db_iemr.t_pnc_visit", "db_iemr.t_pnccare", "db_iemr.t_pregnant_woman_register", + "db_iemr.t_sam_visit", "db_iemr.uwin_session_record", "db_iemr.vhnc_form", "db_iemr.vhnd_form", + "db_identity.i_cbac_additional_details", "db_identity.i_cbacdetails"); + + @Test + void everyMappedTableIsSyncedOrDeliberatelyNot() throws Exception { + Set synced = new HashSet<>(); + for (StopTBSyncType t : StopTBSyncType.values()) synced.addAll(t.sourceTables()); + + List unclassified = new ArrayList<>(); + for (String table : mappedTables()) { + if (!synced.contains(table) && !NOT_SYNCED_STOPTB.containsKey(table) && !OTHER_PROGRAMMES.contains(table)) { + unclassified.add(table); + } + } + Collections.sort(unclassified); + assertTrue(unclassified.isEmpty(), "Tables neither synced to STOP-TB tablets nor listed as not synced: " + + unclassified + ". If the STOP-TB app reads it, add it to a StopTBSyncType's sourceTables and add " + + "change-log triggers in a new AMRIT-DB migration; otherwise list it here with a reason."); + } + + @Test + void syncedTablesAreNotAlsoListedAsNotSynced() { + for (StopTBSyncType t : StopTBSyncType.values()) { + for (String table : t.sourceTables()) { + assertTrue(!NOT_SYNCED_STOPTB.containsKey(table) && !OTHER_PROGRAMMES.contains(table), table); + } + } + } + + /** schema.table for every @Entity under com.iemr.flw.domain, lower-case. */ + static Set mappedTables() throws ClassNotFoundException { + ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); + Set tables = new TreeSet<>(); + for (BeanDefinition bd : scanner.findCandidateComponents("com.iemr.flw.domain")) { + Class c = Class.forName(bd.getBeanClassName()); + Table t = c.getAnnotation(Table.class); + String name = t != null && !t.name().isEmpty() ? t.name() : c.getSimpleName(); + String schema = t != null && !t.schema().isEmpty() ? t.schema() + : c.getPackageName().endsWith(".identity") ? "db_identity" : "db_iemr"; + tables.add((schema + "." + name).toLowerCase(Locale.ROOT)); + } + return tables; + } +} diff --git a/src/test/java/com/iemr/flw/service/sync/StopTBSyncTypeTest.java b/src/test/java/com/iemr/flw/service/sync/StopTBSyncTypeTest.java new file mode 100644 index 000000000..a7fa90e80 --- /dev/null +++ b/src/test/java/com/iemr/flw/service/sync/StopTBSyncTypeTest.java @@ -0,0 +1,21 @@ +package com.iemr.flw.service.sync; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StopTBSyncTypeTest { + + @Test + void childKeyQueriesTargetTheGivenBeneficiariesWithoutPaging() { + for (StopTBSyncType t : StopTBSyncType.values()) { + if (t == StopTBSyncType.BENEFICIARY) continue; + String sql = t.keysForBeneficiariesSql(); + assertTrue(sql.contains(":regIds"), t + ": " + sql); + assertFalse(sql.contains(":after"), t + ": " + sql); + assertFalse(sql.contains(":limit"), t + ": " + sql); + assertFalse(sql.contains(":villages"), t + ": " + sql); + System.out.println(t + " | " + sql); + } + } +} diff --git a/src/test/java/com/iemr/flw/service/sync/SyncTokenTest.java b/src/test/java/com/iemr/flw/service/sync/SyncTokenTest.java new file mode 100644 index 000000000..3f247605b --- /dev/null +++ b/src/test/java/com/iemr/flw/service/sync/SyncTokenTest.java @@ -0,0 +1,51 @@ +package com.iemr.flw.service.sync; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SyncTokenTest { + + private static final String UUID = "3f2a6c1e-1b2c-11ef-9f1a-0242ac120002"; + + @Test + void deltaRoundTrips() { + SyncToken t = SyncToken.parse(SyncToken.delta(UUID, 1000, 1761700000L, 0b101, 42L).encode()); + assertEquals(SyncToken.Kind.DELTA, t.kind()); + assertEquals(UUID, t.serverId()); + assertEquals(1000, t.seq()); + assertEquals(1761700000L, t.issuedAt()); + assertEquals(0b101, t.typesMask()); + assertEquals(42L, t.scopeHash()); + } + + @Test + void snapshotRoundTrips() { + SyncToken t = SyncToken.parse(SyncToken.snapshot(UUID, 5230, 3, 900, 7, 99L).encode()); + assertEquals(SyncToken.Kind.SNAPSHOT, t.kind()); + assertEquals(5230, t.seq()); + assertEquals(3, t.typeIndex()); + assertEquals(900, t.afterKey()); + assertEquals(7, t.typesMask()); + assertEquals(99L, t.scopeHash()); + } + + @Test + void unreadableTokensMeanStartOver() { + assertNull(SyncToken.parse(null)); + assertNull(SyncToken.parse("")); + assertNull(SyncToken.parse("garbage")); + assertNull(SyncToken.parse("d." + UUID + ".x.1.1.1")); + assertNull(SyncToken.parse("d." + UUID + ".1.1.1")); // no scope: issued before scope was bound + assertNull(SyncToken.parse("d..1.1.1.1")); + assertNull(SyncToken.parse("q." + UUID + ".1.1")); + } + + @Test + void everyLogEntityMapsToOneType() { + for (StopTBSyncType t : StopTBSyncType.values()) { + assertSame(t, StopTBSyncType.fromLogEntity(t.logEntity())); + } + assertNull(StopTBSyncType.fromLogEntity("unknown")); + } +} diff --git a/src/test/java/com/iemr/flw/service/sync/UserSyncScopeTest.java b/src/test/java/com/iemr/flw/service/sync/UserSyncScopeTest.java new file mode 100644 index 000000000..1398bbbdf --- /dev/null +++ b/src/test/java/com/iemr/flw/service/sync/UserSyncScopeTest.java @@ -0,0 +1,42 @@ +package com.iemr.flw.service.sync; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class UserSyncScopeTest { + + // Two mapping rows for service 1738 (villages merge), one for 1744, and a malformed entry. + private final UserSyncScope scope = UserSyncScope.fromMappingRows(List.of( + new Object[]{1738, "5443, 5444"}, + new Object[]{1738, "5445,x"}, + new Object[]{1744, "6677"}, + new Object[]{1750, null})); + + @Test + void noVillagesAskedForMeansAllOfTheUsersVillagesForThatService() { + assertEquals(List.of(5443, 5444, 5445), scope.resolve(1738, List.of())); + assertEquals(List.of(6677), scope.resolve(1744, null)); + } + + @Test + void askedForVillagesMustAllBeAssigned() { + assertEquals(List.of(5443, 5445), scope.resolve(1738, List.of(5445, 5443, 5445))); + assertThrows(IllegalArgumentException.class, () -> scope.resolve(1738, List.of(5443, 6677))); + } + + @Test + void unmappedServiceOrServiceWithoutVillagesIsRejected() { + assertThrows(IllegalArgumentException.class, () -> scope.resolve(9999, List.of())); + assertThrows(IllegalArgumentException.class, () -> scope.resolve(1750, List.of())); + } + + @Test + void scopeHashIgnoresVillageOrderButNotContent() { + assertEquals(UserSyncScope.scopeHash(1738, List.of(5443, 5444)), UserSyncScope.scopeHash(1738, List.of(5444, 5443))); + assertNotEquals(UserSyncScope.scopeHash(1738, List.of(5443)), UserSyncScope.scopeHash(1738, List.of(5443, 5444))); + assertNotEquals(UserSyncScope.scopeHash(1738, List.of(5443)), UserSyncScope.scopeHash(1744, List.of(5443))); + } +}