From 43169c24ec12b7ac584fb847fb15b548a51bbf02 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Mon, 26 Oct 2026 14:07:47 +0530 Subject: [PATCH 1/4] stop:647 Nikshay integration's device configuration and designated PHI mapping. --- src/main/environment/admin_ci.properties | 3 + src/main/environment/admin_docker.properties | 3 + src/main/environment/admin_example.properties | 2 + .../NikshayDesignatedPhiController.java | 107 ++++++ .../nikshay/NikshayDeviceController.java | 123 ++++++ .../nikshay/NikshayDeviceConfiguration.java | 86 +++++ .../admin/data/nikshay/NikshayDeviceName.java | 23 ++ .../nikshay/NikshayDeviceVillageMapping.java | 90 +++++ .../NikshayTransferOutPhiVillageMapping.java | 81 ++++ .../model/nikshay/NikshayActivateRequest.java | 14 + .../nikshay/NikshayDesignatedPhiRequest.java | 21 + .../model/nikshay/NikshayDeviceRequest.java | 27 ++ .../model/nikshay/NikshayDeviceResponse.java | 25 ++ .../nikshay/NikshayVillageEnrollRequest.java | 27 ++ .../nikshay/NikshayVillageRemoveRequest.java | 15 + .../NikshayDeviceConfigurationRepo.java | 27 ++ .../NikshayDeviceVillageMappingRepo.java | 63 +++ .../nikshay/NikshayTransferOutPhiRepo.java | 51 +++ .../nikshay/NikshayDesignatedPhiService.java | 21 + .../NikshayDesignatedPhiServiceImpl.java | 218 +++++++++++ .../service/nikshay/NikshayDeviceService.java | 34 ++ .../nikshay/NikshayDeviceServiceImpl.java | 363 ++++++++++++++++++ .../service/nikshay/NikshayRowUtils.java | 78 ++++ .../utils/crypto/NikshayPasswordCipher.java | 109 ++++++ 24 files changed, 1611 insertions(+) create mode 100644 src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java create mode 100644 src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceName.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceVillageMapping.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayTransferOutPhiVillageMapping.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayActivateRequest.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayDesignatedPhiRequest.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayVillageRemoveRequest.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshay/NikshayTransferOutPhiRepo.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayRowUtils.java create mode 100644 src/main/java/com/iemr/admin/utils/crypto/NikshayPasswordCipher.java diff --git a/src/main/environment/admin_ci.properties b/src/main/environment/admin_ci.properties index 92823806..75042e11 100644 --- a/src/main/environment/admin_ci.properties +++ b/src/main/environment/admin_ci.properties @@ -26,3 +26,6 @@ springdoc.api-docs.enabled=@env.SWAGGER_DOC_ENABLED@ springdoc.swagger-ui.enabled=@env.SWAGGER_DOC_ENABLED@ cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ + +# Ni-kshay device PHI password encryption key (Base64, 32 bytes); MMU-API must use the same key +nikshay.device.aes-key=@env.NIKSHAY_DEVICE_AES_KEY@ \ No newline at end of file diff --git a/src/main/environment/admin_docker.properties b/src/main/environment/admin_docker.properties index d530138a..e21387c3 100644 --- a/src/main/environment/admin_docker.properties +++ b/src/main/environment/admin_docker.properties @@ -22,3 +22,6 @@ common-url=${COMMON_API} springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED} springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED} + +# Ni-kshay device PHI password encryption key (Base64, 32 bytes); MMU-API must use the same key +nikshay.device.aes-key=${NIKSHAY_DEVICE_AES_KEY:} \ No newline at end of file diff --git a/src/main/environment/admin_example.properties b/src/main/environment/admin_example.properties index f510b42b..ecba7093 100644 --- a/src/main/environment/admin_example.properties +++ b/src/main/environment/admin_example.properties @@ -26,3 +26,5 @@ logging.file.name=logs/admin-api.log jwt.secret=my-32-character-ultra-secure-and-ultra-long-secret cors.allowed-origins=http://localhost:* + +nikshay.device.aes-key=${NIKSHAY_DEVICE_AES_KEY:} diff --git a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java new file mode 100644 index 00000000..a6ff8ab8 --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java @@ -0,0 +1,107 @@ +package com.iemr.admin.controller.nikshay; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDesignatedPhiRequest; +import com.iemr.admin.service.nikshay.NikshayDesignatedPhiService; +import com.iemr.admin.utils.response.OutputResponse; + +import io.swagger.v3.oas.annotations.Operation; + +/** + * Designated PHI of each Ni-kshay village, used by the transfer out phase of + * the Ni-kshay job (Module 1 of the Ni-kshay integration). Responses keep null + * fields (e.g. designationHierarchyId) so every row has the same keys. + */ +@RestController +@RequestMapping(value = "/nikshay/designatedPhi") +public class NikshayDesignatedPhiController { + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + + @Autowired + private NikshayDesignatedPhiService nikshayDesignatedPhiService; + + @Operation(summary = "Save a designated PHI for a village (a village can have several); saving the same village, TBU and PHI again updates it") + @PostMapping(value = "/save", produces = "application/json", headers = "Authorization") + public String saveDesignatedPhi(@RequestBody NikshayDesignatedPhiRequest request) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(GSON.toJson(nikshayDesignatedPhiService.saveDesignatedPhi(request))); + } catch (Exception e) { + logger.error("saveDesignatedPhi failed: " + e.getMessage(), e); + response.setError(e); + } + return response.toStringWithSerialization(); + } + + @Operation(summary = "Update a designated PHI mapping by id; only the fields sent (not null) are changed") + @PostMapping(value = "/update", produces = "application/json", headers = "Authorization") + public String updateDesignatedPhi(@RequestBody NikshayDesignatedPhiRequest request) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(GSON.toJson(nikshayDesignatedPhiService.updateDesignatedPhi(request))); + } catch (Exception e) { + logger.error("updateDesignatedPhi failed: " + e.getMessage(), e); + response.setError(e); + } + return response.toStringWithSerialization(); + } + + @Operation(summary = "Activate / deactivate a designated PHI mapping") + @PostMapping(value = "/activate", produces = "application/json", headers = "Authorization") + public String setActive(@RequestBody NikshayActivateRequest request) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(GSON.toJson(nikshayDesignatedPhiService.setActive(request))); + } catch (Exception e) { + logger.error("setActive designated PHI failed: " + e.getMessage(), e); + response.setError(e); + } + return response.toStringWithSerialization(); + } + + @Operation(summary = "Mark a designated PHI mapping inactive by id (body: id, updatedBy)") + @PostMapping(value = "/markInactive", produces = "application/json", headers = "Authorization") + public String markInactive(@RequestBody NikshayActivateRequest request) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(GSON.toJson(nikshayDesignatedPhiService.markInactive(request))); + } catch (Exception e) { + logger.error("markInactive designated PHI failed: " + e.getMessage(), e); + response.setError(e); + } + return response.toStringWithSerialization(); + } + + @Operation(summary = "Get designated PHI by village, TBU or district. unmappedOnly=true lists villages " + + "without an active designated PHI; activeOnly=false includes inactive rows") + @GetMapping(value = "/get", produces = "application/json", headers = "Authorization") + public String getDesignatedPhi(@RequestParam(value = "villageId", required = false) Integer villageId, + @RequestParam(value = "tbuId", required = false) Integer tbuId, + @RequestParam(value = "districtId", required = false) Integer districtId, + @RequestParam(value = "activeOnly", required = false, defaultValue = "true") Boolean activeOnly, + @RequestParam(value = "unmappedOnly", required = false, defaultValue = "false") Boolean unmappedOnly) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(GSON.toJson( + nikshayDesignatedPhiService.getDesignatedPhi(villageId, tbuId, districtId, activeOnly, unmappedOnly))); + } catch (Exception e) { + logger.error("getDesignatedPhi failed: " + e.getMessage(), e); + response.setError(e); + } + return response.toStringWithSerialization(); + } +} diff --git a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java new file mode 100644 index 00000000..2a9f9eac --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java @@ -0,0 +1,123 @@ +package com.iemr.admin.controller.nikshay; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceRequest; +import com.iemr.admin.model.nikshay.NikshayVillageEnrollRequest; +import com.iemr.admin.model.nikshay.NikshayVillageRemoveRequest; +import com.iemr.admin.service.nikshay.NikshayDeviceService; +import com.iemr.admin.utils.response.OutputResponse; + +import io.swagger.v3.oas.annotations.Operation; + +/** + * Ni-kshay camp device configuration (Module 1 of the Ni-kshay integration): + * devices with their PHI login, and the villages each device serves. + * Request bodies are not logged here because device requests carry passwords. + */ +@RestController +@RequestMapping(value = "/nikshay/device") +public class NikshayDeviceController { + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + + @Autowired + private NikshayDeviceService nikshayDeviceService; + + @Operation(summary = "Create a Ni-kshay camp device (device name NAAT / XRAY) with its PHI login; the password is stored encrypted") + @PostMapping(value = "/create", produces = "application/json", headers = "Authorization") + public String createDevice(@RequestBody NikshayDeviceRequest request) { + return respond("createDevice", () -> nikshayDeviceService.createDevice(request)); + } + + @Operation(summary = "Update a device; the password changes only when a new one is sent") + @PostMapping(value = "/update", produces = "application/json", headers = "Authorization") + public String updateDevice(@RequestBody NikshayDeviceRequest request) { + return respond("updateDevice", () -> nikshayDeviceService.updateDevice(request)); + } + + @Operation(summary = "Activate / deactivate a device; deactivating also removes it from its villages") + @PostMapping(value = "/activate", produces = "application/json", headers = "Authorization") + public String setDeviceActive(@RequestBody NikshayActivateRequest request) { + return respond("setDeviceActive", () -> nikshayDeviceService.setDeviceActive(request)); + } + + @Operation(summary = "List devices (without password), optionally by device name (NAAT / XRAY) and active flag") + @GetMapping(value = "/list", produces = "application/json", headers = "Authorization") + public String listDevices(@RequestParam(value = "deviceName", required = false) String deviceName, + @RequestParam(value = "isActive", required = false) Boolean isActive) { + return respond("listDevices", () -> nikshayDeviceService.listDevices(deviceName, isActive)); + } + + @Operation(summary = "Search devices by device ID (partial, case-insensitive; exact match first), " + + "without password, each with its active villages") + @GetMapping(value = "/search", produces = "application/json", headers = "Authorization") + public String searchByDeviceId(@RequestParam("deviceId") String deviceId) { + return respond("searchByDeviceId", () -> nikshayDeviceService.searchByDeviceId(deviceId)); + } + + @Operation(summary = "Get one device (without password) with its active villages") + @GetMapping(value = "/get", produces = "application/json", headers = "Authorization") + public String getDevice(@RequestParam("id") Integer id) { + return respond("getDevice", () -> nikshayDeviceService.getDevice(id)); + } + + @Operation(summary = "Enroll a device for one or more Ni-kshay villages. A village can have one active NAAT and one active XRAY device") + @PostMapping(value = "/village/enroll", produces = "application/json", headers = "Authorization") + public String enrollVillages(@RequestBody NikshayVillageEnrollRequest request) { + return respond("enrollVillages", () -> nikshayDeviceService.enrollVillages(request)); + } + + @Operation(summary = "Remove a device from villages (marks the mappings inactive)") + @PostMapping(value = "/village/remove", produces = "application/json", headers = "Authorization") + public String removeVillages(@RequestBody NikshayVillageRemoveRequest request) { + return respond("removeVillages", () -> nikshayDeviceService.removeVillages(request)); + } + + @Operation(summary = "Villages served by a device") + @GetMapping(value = "/village/list", produces = "application/json", headers = "Authorization") + public String getVillagesOfDevice(@RequestParam("deviceDetailId") Integer deviceDetailId, + @RequestParam(value = "activeOnly", required = false, defaultValue = "true") Boolean activeOnly) { + return respond("getVillagesOfDevice", () -> nikshayDeviceService.getVillagesOfDevice(deviceDetailId, activeOnly)); + } + + @Operation(summary = "Devices and PHI details (without password) mapped to a Ni-kshay location") + @GetMapping(value = "/phiByLocation", produces = "application/json", headers = "Authorization") + public String getPhiByLocation(@RequestParam(value = "stateId", required = false) Integer stateId, + @RequestParam(value = "districtId", required = false) Integer districtId, + @RequestParam(value = "tbuId", required = false) Integer tbuId, + @RequestParam(value = "facilityId", required = false) Integer facilityId, + @RequestParam(value = "villageId", required = false) Integer villageId, + @RequestParam(value = "deviceName", required = false) String deviceName) { + return respond("getPhiByLocation", () -> nikshayDeviceService.getPhiByLocation(stateId, districtId, tbuId, + facilityId, villageId, deviceName)); + } + + private String respond(String action, ThrowingSupplier call) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(GSON.toJson(call.get())); + } catch (Exception e) { + logger.error(action + " failed: " + e.getMessage(), e); + response.setError(e); + } + return response.toString(); + } + + @FunctionalInterface + interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java new file mode 100644 index 00000000..fcf2275e --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java @@ -0,0 +1,86 @@ +package com.iemr.admin.data.nikshay; + +import java.sql.Timestamp; + +import com.google.gson.annotations.Expose; +import com.iemr.admin.utils.mapper.OutputMapper; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import lombok.Data; + +/** + * One TBMJA camp device (device_name NAAT or XRAY) and the Ni-kshay PHI login created for + * it. The Ni-kshay job logs in with the device of the beneficiary's village: + * the NAAT device for ID generation, NAAT results, contacts and transfer out, + * the XRAY device for X-ray results. + * + * The password is stored AES encrypted (see NikshayPasswordCipher) and has no + * {@link Expose}, so it is never part of toString() or any API response. + */ +@Data +@Entity +@Table(name = "m_nikshay_device_configuration") +public class NikshayDeviceConfiguration { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Expose + @Column(name = "id") + private Integer id; + + @Expose + @Column(name = "device_id", unique = true) + private String deviceId; + + @Expose + @Enumerated(EnumType.STRING) + @Column(name = "device_name") + private NikshayDeviceName deviceName; + + @Expose + @Column(name = "phi_user_id") + private String phiUserId; + + @Column(name = "password") + private String password; + + @Expose + @Column(name = "is_active") + private Boolean isActive = true; + + @Expose + @Column(name = "created_by") + private String createdBy; + + @Expose + @Column(name = "created_at", insertable = false, updatable = false) + private Timestamp createdAt; + + @Expose + @Column(name = "updated_by") + private String updatedBy; + + @Expose + @Column(name = "updated_at", insertable = false, updatable = false) + private Timestamp updatedAt; + + @Expose + @Column(name = "more_data", columnDefinition = "json") + private String moreData; + + @Transient + private OutputMapper outputMapper = new OutputMapper(); + + @Override + public String toString() { + return outputMapper.gson().toJson(this); + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceName.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceName.java new file mode 100644 index 00000000..570a3ef5 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceName.java @@ -0,0 +1,23 @@ +package com.iemr.admin.data.nikshay; + +/** + * Name of a TBMJA camp device, stored as a string in + * m_nikshay_device_configuration.device_name. It decides which activities the + * device's PHI login is used for: NAAT for ID generation, NAAT results, + * contacts and transfer out; XRAY for X-ray results. + */ +public enum NikshayDeviceName { + NAAT, XRAY; + + /** Case-insensitive parse with a readable error for API input. */ + public static NikshayDeviceName from(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Device name is required"); + } + try { + return valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Device name must be NAAT or XRAY"); + } + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceVillageMapping.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceVillageMapping.java new file mode 100644 index 00000000..d7f4c927 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceVillageMapping.java @@ -0,0 +1,90 @@ +package com.iemr.admin.data.nikshay; + +import java.sql.Timestamp; + +import com.google.gson.annotations.Expose; +import com.iemr.admin.utils.mapper.OutputMapper; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import lombok.Data; + +/** + * A village served by a device (m_nikshay_device_configuration). One device serves + * many villages; a village has at most one active device of each type (NAAT, + * XRAY). All location IDs are + * Ni-kshay IDs (m_nikshay_*). The job joins the beneficiary's + * i_beneficiaryaddress.PermVillageId with village_id to find the device login. + */ +@Data +@Entity +@Table(name = "m_nikshay_device_village_mapping") +public class NikshayDeviceVillageMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Expose + @Column(name = "id") + private Integer id; + + @Expose + @Column(name = "device_detail_id") + private Integer deviceDetailId; + + @Expose + @Column(name = "state_id") + private Integer stateId; + + @Expose + @Column(name = "district_id") + private Integer districtId; + + @Expose + @Column(name = "tbu_id") + private Integer tbuId; + + @Expose + @Column(name = "facility_id") + private Integer facilityId; + + @Expose + @Column(name = "village_id") + private Integer villageId; + + @Expose + @Column(name = "is_active") + private Boolean isActive = true; + + @Expose + @Column(name = "created_by") + private String createdBy; + + @Expose + @Column(name = "created_at", insertable = false, updatable = false) + private Timestamp createdAt; + + @Expose + @Column(name = "updated_by") + private String updatedBy; + + @Expose + @Column(name = "updated_at", insertable = false, updatable = false) + private Timestamp updatedAt; + + @Expose + @Column(name = "more_data", columnDefinition = "json") + private String moreData; + + @Transient + private OutputMapper outputMapper = new OutputMapper(); + + @Override + public String toString() { + return outputMapper.gson().toJson(this); + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayTransferOutPhiVillageMapping.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayTransferOutPhiVillageMapping.java new file mode 100644 index 00000000..193318ed --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayTransferOutPhiVillageMapping.java @@ -0,0 +1,81 @@ +package com.iemr.admin.data.nikshay; + +import java.sql.Timestamp; + +import com.google.gson.annotations.Expose; +import com.iemr.admin.utils.mapper.OutputMapper; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import lombok.Data; + +/** + * Designated PHI of a Ni-kshay village, used by the transfer out phase. A + * village can have several active rows; village + TBU + PHI is unique among + * active rows. + */ +@Data +@Entity +@Table(name = "m_nikshay_transfer_out_phi_village_mapping") +public class NikshayTransferOutPhiVillageMapping { + + public static final String STATUS_ACTIVE = "active"; + public static final String STATUS_INACTIVE = "inactive"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Expose + @Column(name = "id") + private Integer id; + + @Expose + @Column(name = "village_id") + private Integer villageId; + + @Expose + @Column(name = "tbu_id") + private Integer tbuId; + + // Ni-kshay facility ID of the designated PHI that receives the transfer + @Expose + @Column(name = "designated_phi_id") + private String designatedPhiId; + + // Ni-kshay hierarchy ID of the designated PHI + @Expose + @Column(name = "designation_hierarchy_id") + private String designationHierarchyId; + + @Expose + @Column(name = "status") + private String status = STATUS_ACTIVE; + + @Expose + @Column(name = "created_by") + private String createdBy; + + @Expose + @Column(name = "created_at", insertable = false, updatable = false) + private Timestamp createdAt; + + @Expose + @Column(name = "updated_by") + private String updatedBy; + + @Expose + @Column(name = "updated_at", insertable = false, updatable = false) + private Timestamp updatedAt; + + @Transient + private OutputMapper outputMapper = new OutputMapper(); + + @Override + public String toString() { + return outputMapper.gson().toJson(this); + } +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayActivateRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayActivateRequest.java new file mode 100644 index 00000000..b72eaa16 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayActivateRequest.java @@ -0,0 +1,14 @@ +package com.iemr.admin.model.nikshay; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +// Activate / deactivate a device or a designated PHI mapping +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayActivateRequest { + private Integer id; + private Boolean isActive; + private String updatedBy; +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayDesignatedPhiRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayDesignatedPhiRequest.java new file mode 100644 index 00000000..72e9f5f0 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayDesignatedPhiRequest.java @@ -0,0 +1,21 @@ +package com.iemr.admin.model.nikshay; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +/** + * Designated PHI for a village. Saving the same village, TBU and designated PHI + * again updates that row (hierarchy ID) instead of adding a duplicate. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayDesignatedPhiRequest { + private Integer id; + private Integer villageId; + private Integer tbuId; + private String designatedPhiId; + private String designationHierarchyId; + private String createdBy; + private String updatedBy; +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java new file mode 100644 index 00000000..89d476ff --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java @@ -0,0 +1,27 @@ +package com.iemr.admin.model.nikshay; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +/** + * Create / update body for a camp device. On update, password is changed only + * when a non-blank value is sent. moreData is any JSON object. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayDeviceRequest { + private Integer id; + private String deviceId; + private String deviceName; + private String phiUserId; + private String password; + private Object moreData; + private String createdBy; + private String updatedBy; + + @Override + public String toString() { + return "NikshayDeviceRequest(id=" + id + ", deviceId=" + deviceId + ", deviceName=" + deviceName + ")"; + } +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java new file mode 100644 index 00000000..55fe724a --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java @@ -0,0 +1,25 @@ +package com.iemr.admin.model.nikshay; + +import java.util.List; +import java.util.Map; + +import com.iemr.admin.data.nikshay.NikshayDeviceName; + +import lombok.Data; + +// Device details as returned by the APIs. Deliberately has no password field. +@Data +public class NikshayDeviceResponse { + private Integer id; + private String deviceId; + private NikshayDeviceName deviceName; + private String phiUserId; + private Boolean isActive; + private Object moreData; + private Long activeVillageCount; + private List> villages; + private String createdBy; + private String createdAt; + private String updatedBy; + private String updatedAt; +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java new file mode 100644 index 00000000..0207f927 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java @@ -0,0 +1,27 @@ +package com.iemr.admin.model.nikshay; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +// Enroll a device (phir_device_configuration.id) for one or more Ni-kshay villages +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayVillageEnrollRequest { + private Integer deviceDetailId; + private String createdBy; + private List villages; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Village { + private Integer stateId; + private Integer districtId; + private Integer tbuId; + private Integer facilityId; + private Integer villageId; + private Object moreData; + } +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageRemoveRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageRemoveRequest.java new file mode 100644 index 00000000..eccb0db9 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageRemoveRequest.java @@ -0,0 +1,15 @@ +package com.iemr.admin.model.nikshay; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +// Remove a device from villages: marks the given device_village_mapping rows inactive +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayVillageRemoveRequest { + private List mappingIds; + private String updatedBy; +} diff --git a/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java new file mode 100644 index 00000000..46b7154c --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java @@ -0,0 +1,27 @@ +package com.iemr.admin.repo.nikshay; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.NikshayDeviceConfiguration; +import com.iemr.admin.data.nikshay.NikshayDeviceName; + +@Repository +public interface NikshayDeviceConfigurationRepo extends CrudRepository { + + NikshayDeviceConfiguration findByDeviceId(String deviceId); + + // Partial, case-insensitive match on the programme device ID; exact matches first + @Query("select d from NikshayDeviceConfiguration d where lower(d.deviceId) like lower(concat('%', :deviceId, '%')) " + + "order by case when lower(d.deviceId) = lower(:deviceId) then 0 else 1 end, d.deviceId") + List searchByDeviceId(@Param("deviceId") String deviceId); + + @Query("select d from NikshayDeviceConfiguration d where (:deviceName is null or d.deviceName = :deviceName) " + + "and (:isActive is null or d.isActive = :isActive) order by d.deviceName, d.id") + List search(@Param("deviceName") NikshayDeviceName deviceName, + @Param("isActive") Boolean isActive); +} diff --git a/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java new file mode 100644 index 00000000..4b0a22b6 --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java @@ -0,0 +1,63 @@ +package com.iemr.admin.repo.nikshay; + +import java.util.List; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.NikshayDeviceName; +import com.iemr.admin.data.nikshay.NikshayDeviceVillageMapping; + +@Repository +public interface NikshayDeviceVillageMappingRepo extends CrudRepository { + + // Active mappings of these villages to devices of one type (NAAT or XRAY) + @Query("select m from NikshayDeviceVillageMapping m, NikshayDeviceConfiguration d where d.id = m.deviceDetailId " + + "and m.villageId in (:villageIds) and m.isActive = true and d.deviceName = :deviceName") + List findActiveByVillageIdsAndDeviceName(@Param("villageIds") List villageIds, + @Param("deviceName") NikshayDeviceName deviceName); + + @Query("select count(m) from NikshayDeviceVillageMapping m where m.deviceDetailId = :deviceDetailId and m.isActive = true") + long countActiveByDevice(@Param("deviceDetailId") Integer deviceDetailId); + + @Modifying + @Query("update NikshayDeviceVillageMapping m set m.isActive = false, m.updatedBy = :updatedBy " + + "where m.deviceDetailId = :deviceDetailId and m.isActive = true") + int deactivateAllForDevice(@Param("deviceDetailId") Integer deviceDetailId, @Param("updatedBy") String updatedBy); + + // row = [id, village_id, VillageName, facility_id, FacilityName, tbu_id, TUName, + // district_id, state_id, is_active, created_by, created_at] + @Query(value = "select m.id, m.village_id, v.VillageName, m.facility_id, f.FacilityName, m.tbu_id, t.TUName, " + + "m.district_id, m.state_id, m.is_active, m.created_by, m.created_at " + + "from m_nikshay_device_village_mapping m " + + "left join m_nikshay_village v on v.NikshayVillageID = m.village_id " + + "left join m_nikshay_facility f on f.NikshayFacilityID = m.facility_id " + + "left join m_nikshay_tu t on t.NikshayTUID = m.tbu_id " + + "where m.device_detail_id = :deviceDetailId and (:activeOnly = false or m.is_active = true) " + + "order by v.VillageName, m.id", nativeQuery = true) + List findVillagesOfDevice(@Param("deviceDetailId") Integer deviceDetailId, + @Param("activeOnly") Boolean activeOnly); + + // Active mappings of active devices for a location. row = [mapping id, village_id, + // VillageName, facility_id, tbu_id, district_id, state_id, device pk id, + // device_id, device_name, phi_user_id]. Never selects the password. + @Query(value = "select m.id, m.village_id, v.VillageName, m.facility_id, m.tbu_id, m.district_id, m.state_id, " + + "d.id as device_detail_id, d.device_id, d.device_name, d.phi_user_id " + + "from m_nikshay_device_village_mapping m " + + "join m_nikshay_device_configuration d on d.id = m.device_detail_id " + + "left join m_nikshay_village v on v.NikshayVillageID = m.village_id " + + "where m.is_active = true and d.is_active = true " + + "and (:stateId is null or m.state_id = :stateId) " + + "and (:districtId is null or m.district_id = :districtId) " + + "and (:tbuId is null or m.tbu_id = :tbuId) " + + "and (:facilityId is null or m.facility_id = :facilityId) " + + "and (:villageId is null or m.village_id = :villageId) " + + "and (:deviceName is null or d.device_name = :deviceName) " + + "order by v.VillageName, m.village_id", nativeQuery = true) + List findPhiByLocation(@Param("stateId") Integer stateId, @Param("districtId") Integer districtId, + @Param("tbuId") Integer tbuId, @Param("facilityId") Integer facilityId, + @Param("villageId") Integer villageId, @Param("deviceName") String deviceName); +} diff --git a/src/main/java/com/iemr/admin/repo/nikshay/NikshayTransferOutPhiRepo.java b/src/main/java/com/iemr/admin/repo/nikshay/NikshayTransferOutPhiRepo.java new file mode 100644 index 00000000..3e13897f --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshay/NikshayTransferOutPhiRepo.java @@ -0,0 +1,51 @@ +package com.iemr.admin.repo.nikshay; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.NikshayTransferOutPhiVillageMapping; + +@Repository +public interface NikshayTransferOutPhiRepo extends CrudRepository { + + // Active rows for a village + TBU + PHI (unique while active) + @Query("select p from NikshayTransferOutPhiVillageMapping p where p.villageId = :villageId and p.tbuId = :tbuId " + + "and p.designatedPhiId = :designatedPhiId and p.status = 'active'") + List findActiveByVillageAndPhi(@Param("villageId") Integer villageId, + @Param("tbuId") Integer tbuId, @Param("designatedPhiId") String designatedPhiId); + + // row = [id, village_id, VillageName, tbu_id, TUName, designated_phi_id, + // designation_hierarchy_id, status, created_by, created_at, updated_by, updated_at] + @Query(value = "select p.id, p.village_id, v.VillageName, p.tbu_id, t.TUName, p.designated_phi_id, p.designation_hierarchy_id, " + + "p.status, p.created_by, p.created_at, p.updated_by, p.updated_at " + + "from m_nikshay_transfer_out_phi_village_mapping p " + + "left join m_nikshay_village v on v.NikshayVillageID = p.village_id " + + "left join m_nikshay_tu t on t.NikshayTUID = p.tbu_id " + + "where (:villageId is null or p.village_id = :villageId) " + + "and (:tbuId is null or p.tbu_id = :tbuId) " + + "and (:districtId is null or t.NikshayDistrictID = :districtId) " + + "and (:activeOnly = false or p.status = 'active') " + + "order by v.VillageName, p.village_id, p.id desc", nativeQuery = true) + List search(@Param("villageId") Integer villageId, @Param("tbuId") Integer tbuId, + @Param("districtId") Integer districtId, @Param("activeOnly") Boolean activeOnly); + + // Villages with no active designated PHI. row = [NikshayVillageID, + // VillageName, NikshayFacilityID, FacilityName, NikshayTUID, TUName] + @Query(value = "select v.NikshayVillageID, v.VillageName, f.NikshayFacilityID, f.FacilityName, t.NikshayTUID, t.TUName " + + "from m_nikshay_village v " + + "join m_nikshay_facility f on f.NikshayFacilityID = v.NikshayFacilityID " + + "join m_nikshay_tu t on t.NikshayTUID = f.NikshayTUID " + + "where v.Deleted = false " + + "and (:villageId is null or v.NikshayVillageID = :villageId) " + + "and (:tbuId is null or t.NikshayTUID = :tbuId) " + + "and (:districtId is null or t.NikshayDistrictID = :districtId) " + + "and not exists (select 1 from m_nikshay_transfer_out_phi_village_mapping p " + + "where p.village_id = v.NikshayVillageID and p.status = 'active') " + + "order by v.VillageName, v.NikshayVillageID", nativeQuery = true) + List findVillagesWithoutDesignatedPhi(@Param("villageId") Integer villageId, + @Param("tbuId") Integer tbuId, @Param("districtId") Integer districtId); +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java new file mode 100644 index 00000000..453d2dfe --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java @@ -0,0 +1,21 @@ +package com.iemr.admin.service.nikshay; + +import java.util.List; +import java.util.Map; + +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDesignatedPhiRequest; + +public interface NikshayDesignatedPhiService { + + Map saveDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception; + + Map updateDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception; + + Map setActive(NikshayActivateRequest request) throws Exception; + + Map markInactive(NikshayActivateRequest request) throws Exception; + + List> getDesignatedPhi(Integer villageId, Integer tbuId, Integer districtId, + Boolean activeOnly, Boolean unmappedOnly) throws Exception; +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java new file mode 100644 index 00000000..81d83f74 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java @@ -0,0 +1,218 @@ +package com.iemr.admin.service.nikshay; + +import static com.iemr.admin.service.nikshay.NikshayRowUtils.require; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.str; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.toInt; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.trimToNull; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.admin.data.nikshay.NikshayTransferOutPhiVillageMapping; +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDesignatedPhiRequest; +import com.iemr.admin.repo.nikshay.NikshayTransferOutPhiRepo; +import com.iemr.admin.repo.nikshay.NikshayVillageRepo; + +/** + * Designated PHI of each Ni-kshay village, read by the transfer out phase. + * A village can have several active rows; village + TBU + PHI is unique among + * active rows. Saving a village + TBU + PHI that is already active updates that row. + */ +@Service +public class NikshayDesignatedPhiServiceImpl implements NikshayDesignatedPhiService { + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + @Autowired + private NikshayTransferOutPhiRepo transferOutPhiRepo; + + @Autowired + private NikshayVillageRepo nikshayVillageRepo; + + @Override + @Transactional(rollbackFor = Exception.class) + public Map saveDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception { + require(request, "Request body is required"); + Integer villageId = require(request.getVillageId(), "Village is required"); + Integer tbuId = require(request.getTbuId(), "TBU is required"); + String phiId = require(request.getDesignatedPhiId(), "Designated PHI is required"); + String hierarchyId = trimToNull(request.getDesignationHierarchyId()); + String createdBy = require(request.getCreatedBy(), "Created by is required"); + requireVillage(villageId); + + // Same village + TBU + PHI already active: update it instead of adding a duplicate + List existing = transferOutPhiRepo.findActiveByVillageAndPhi(villageId, + tbuId, phiId); + NikshayTransferOutPhiVillageMapping mapping; + if (existing.isEmpty()) { + mapping = new NikshayTransferOutPhiVillageMapping(); + mapping.setVillageId(villageId); + mapping.setTbuId(tbuId); + mapping.setDesignatedPhiId(phiId); + mapping.setStatus(NikshayTransferOutPhiVillageMapping.STATUS_ACTIVE); + mapping.setCreatedBy(createdBy); + } else { + mapping = existing.get(0); + mapping.setUpdatedBy(createdBy); + } + mapping.setDesignationHierarchyId(hierarchyId); + mapping = transferOutPhiRepo.save(mapping); + logger.info("Designated PHI {} {} for village {} by {}", phiId, existing.isEmpty() ? "added" : "updated", + villageId, createdBy); + return toMap(mapping); + } + + /** + * Edits a mapping by id. Only id and updatedBy are required; every other + * field is changed only when it is sent (not null / not blank). Rejected + * when it would make a second active row for the same village + TBU + PHI. + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map updateDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception { + require(request, "Request body is required"); + Integer id = require(request.getId(), "Mapping id is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + NikshayTransferOutPhiVillageMapping mapping = transferOutPhiRepo.findById(id) + .orElseThrow(() -> new IllegalArgumentException("No designated PHI mapping found with id " + id)); + + // Only the fields that are sent (not null / not blank) are changed + if (request.getVillageId() != null) { + requireVillage(request.getVillageId()); + mapping.setVillageId(request.getVillageId()); + } + if (request.getTbuId() != null) { + mapping.setTbuId(request.getTbuId()); + } + if (trimToNull(request.getDesignatedPhiId()) != null) { + mapping.setDesignatedPhiId(request.getDesignatedPhiId().trim()); + } + if (trimToNull(request.getDesignationHierarchyId()) != null) { + mapping.setDesignationHierarchyId(request.getDesignationHierarchyId().trim()); + } + if (NikshayTransferOutPhiVillageMapping.STATUS_ACTIVE.equals(mapping.getStatus())) { + rejectDuplicateActive(id, mapping.getVillageId(), mapping.getTbuId(), mapping.getDesignatedPhiId()); + } + mapping.setUpdatedBy(updatedBy); + mapping = transferOutPhiRepo.save(mapping); + logger.info("Designated PHI mapping {} updated by {}", id, updatedBy); + return toMap(mapping); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map setActive(NikshayActivateRequest request) throws Exception { + require(request, "Request body is required"); + Integer id = require(request.getId(), "Mapping id is required"); + Boolean active = require(request.getIsActive(), "isActive is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + NikshayTransferOutPhiVillageMapping mapping = transferOutPhiRepo.findById(id) + .orElseThrow(() -> new IllegalArgumentException("No designated PHI mapping found with id " + id)); + + if (active) { + rejectDuplicateActive(mapping.getId(), mapping.getVillageId(), mapping.getTbuId(), + mapping.getDesignatedPhiId()); + } + mapping.setStatus(active ? NikshayTransferOutPhiVillageMapping.STATUS_ACTIVE + : NikshayTransferOutPhiVillageMapping.STATUS_INACTIVE); + mapping.setUpdatedBy(updatedBy); + return toMap(transferOutPhiRepo.save(mapping)); + } + + // Only id and updatedBy are used; an already inactive mapping is returned unchanged + @Override + @Transactional(rollbackFor = Exception.class) + public Map markInactive(NikshayActivateRequest request) throws Exception { + require(request, "Request body is required"); + Integer id = require(request.getId(), "Mapping id is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + NikshayTransferOutPhiVillageMapping mapping = transferOutPhiRepo.findById(id) + .orElseThrow(() -> new IllegalArgumentException("No designated PHI mapping found with id " + id)); + + if (NikshayTransferOutPhiVillageMapping.STATUS_INACTIVE.equals(mapping.getStatus())) { + return toMap(mapping); + } + mapping.setStatus(NikshayTransferOutPhiVillageMapping.STATUS_INACTIVE); + mapping.setUpdatedBy(updatedBy); + mapping = transferOutPhiRepo.save(mapping); + logger.info("Designated PHI mapping {} marked inactive by {}", id, updatedBy); + return toMap(mapping); + } + + @Override + public List> getDesignatedPhi(Integer villageId, Integer tbuId, Integer districtId, + Boolean activeOnly, Boolean unmappedOnly) throws Exception { + if (villageId == null && tbuId == null && districtId == null) { + throw new IllegalArgumentException("Village, TBU or district is required"); + } + List> result = new ArrayList<>(); + if (Boolean.TRUE.equals(unmappedOnly)) { + // row layout: see NikshayTransferOutPhiRepo.findVillagesWithoutDesignatedPhi + for (Object[] row : transferOutPhiRepo.findVillagesWithoutDesignatedPhi(villageId, tbuId, districtId)) { + Map m = new LinkedHashMap<>(); + m.put("villageId", toInt(row[0])); + m.put("villageName", row[1]); + m.put("facilityId", toInt(row[2])); + m.put("facilityName", row[3]); + m.put("tbuId", toInt(row[4])); + m.put("tbuName", row[5]); + result.add(m); + } + return result; + } + // row layout: see NikshayTransferOutPhiRepo.search + for (Object[] row : transferOutPhiRepo.search(villageId, tbuId, districtId, !Boolean.FALSE.equals(activeOnly))) { + Map m = new LinkedHashMap<>(); + m.put("id", toInt(row[0])); + m.put("villageId", toInt(row[1])); + m.put("villageName", row[2]); + m.put("tbuId", toInt(row[3])); + m.put("tbuName", row[4]); + m.put("designatedPhiId", str(row[5])); + m.put("designationHierarchyId", str(row[6])); + m.put("status", row[7]); + m.put("createdBy", row[8]); + m.put("createdAt", str(row[9])); + m.put("updatedBy", row[10]); + m.put("updatedAt", str(row[11])); + result.add(m); + } + return result; + } + + private void requireVillage(Integer villageId) { + nikshayVillageRepo.findById(villageId) + .filter(v -> !Boolean.TRUE.equals(v.getDeleted())) + .orElseThrow(() -> new IllegalArgumentException("No Ni-kshay village found with id " + villageId)); + } + + // A village may have several PHIs, but each village + TBU + PHI only once as active + private void rejectDuplicateActive(Integer mappingId, Integer villageId, Integer tbuId, String phiId) { + for (NikshayTransferOutPhiVillageMapping other : transferOutPhiRepo.findActiveByVillageAndPhi(villageId, tbuId, + phiId)) { + if (!other.getId().equals(mappingId)) { + throw new IllegalArgumentException("Village " + villageId + " already has designated PHI " + phiId + + " active under TBU " + tbuId + " (mapping " + other.getId() + ")"); + } + } + } + + private Map toMap(NikshayTransferOutPhiVillageMapping p) { + Map m = new LinkedHashMap<>(); + m.put("id", p.getId()); + m.put("villageId", p.getVillageId()); + m.put("tbuId", p.getTbuId()); + m.put("designatedPhiId", p.getDesignatedPhiId()); + m.put("designationHierarchyId", p.getDesignationHierarchyId()); + m.put("status", p.getStatus()); + return m; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java new file mode 100644 index 00000000..03b9b952 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java @@ -0,0 +1,34 @@ +package com.iemr.admin.service.nikshay; + +import java.util.List; +import java.util.Map; + +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceResponse; +import com.iemr.admin.model.nikshay.NikshayVillageEnrollRequest; +import com.iemr.admin.model.nikshay.NikshayVillageRemoveRequest; + +public interface NikshayDeviceService { + + NikshayDeviceResponse createDevice(NikshayDeviceRequest request) throws Exception; + + NikshayDeviceResponse updateDevice(NikshayDeviceRequest request) throws Exception; + + NikshayDeviceResponse setDeviceActive(NikshayActivateRequest request) throws Exception; + + List listDevices(String deviceName, Boolean isActive) throws Exception; + + NikshayDeviceResponse getDevice(Integer id) throws Exception; + + List searchByDeviceId(String deviceId) throws Exception; + + Map enrollVillages(NikshayVillageEnrollRequest request) throws Exception; + + Map removeVillages(NikshayVillageRemoveRequest request) throws Exception; + + List> getVillagesOfDevice(Integer deviceDetailId, Boolean activeOnly) throws Exception; + + List> getPhiByLocation(Integer stateId, Integer districtId, Integer tbuId, + Integer facilityId, Integer villageId, String deviceName) throws Exception; +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java new file mode 100644 index 00000000..b4300342 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java @@ -0,0 +1,363 @@ +package com.iemr.admin.service.nikshay; + +import static com.iemr.admin.service.nikshay.NikshayRowUtils.fromJson; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.require; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.str; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.toBool; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.toInt; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.toJson; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.trimToNull; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.admin.data.nikshay.NikshayDeviceConfiguration; +import com.iemr.admin.data.nikshay.NikshayDeviceName; +import com.iemr.admin.data.nikshay.NikshayDeviceVillageMapping; +import com.iemr.admin.data.nikshay.NikshayVillage; +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceResponse; +import com.iemr.admin.model.nikshay.NikshayVillageEnrollRequest; +import com.iemr.admin.model.nikshay.NikshayVillageRemoveRequest; +import com.iemr.admin.repo.nikshay.NikshayDeviceConfigurationRepo; +import com.iemr.admin.repo.nikshay.NikshayDeviceVillageMappingRepo; +import com.iemr.admin.repo.nikshay.NikshayVillageRepo; +import com.iemr.admin.utils.crypto.NikshayPasswordCipher; + +/** + * Camp devices (NAAT / XRAY) with their Ni-kshay PHI login, and the villages + * each device serves. A device serves many villages; a village has at most one + * active device of each type (one NAAT, one XRAY), so the Ni-kshay job always + * finds exactly one login of the type it needs for a beneficiary's village. Passwords are encrypted before saving and never + * returned or logged. + */ +@Service +public class NikshayDeviceServiceImpl implements NikshayDeviceService { + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + @Autowired + private NikshayDeviceConfigurationRepo deviceRepo; + + @Autowired + private NikshayDeviceVillageMappingRepo mappingRepo; + + @Autowired + private NikshayVillageRepo nikshayVillageRepo; + + @Autowired + private NikshayPasswordCipher passwordCipher; + + @Override + @Transactional(rollbackFor = Exception.class) + public NikshayDeviceResponse createDevice(NikshayDeviceRequest request) throws Exception { + require(request, "Request body is required"); + String deviceId = require(request.getDeviceId(), "Device ID is required"); + String password = require(request.getPassword(), "Password is required"); + String createdBy = require(request.getCreatedBy(), "Created by is required"); + + if (deviceRepo.findByDeviceId(deviceId) != null) { + throw new IllegalArgumentException("A device with Device ID " + deviceId + " already exists"); + } + + NikshayDeviceConfiguration device = new NikshayDeviceConfiguration(); + device.setDeviceId(deviceId); + device.setDeviceName(NikshayDeviceName.from(request.getDeviceName())); + device.setPhiUserId(require(request.getPhiUserId(), "PHI user ID is required")); + device.setPassword(passwordCipher.encrypt(password)); + device.setIsActive(true); + device.setMoreData(toJson(request.getMoreData())); + device.setCreatedBy(createdBy); + device = deviceRepo.save(device); + logger.info("Nikshay device {} created by {}", deviceId, createdBy); + return toResponse(device); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public NikshayDeviceResponse updateDevice(NikshayDeviceRequest request) throws Exception { + require(request, "Request body is required"); + NikshayDeviceConfiguration device = findDevice(require(request.getId(), "Device id is required")); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + + String deviceId = trimToNull(request.getDeviceId()); + if (deviceId != null && !deviceId.equals(device.getDeviceId())) { + NikshayDeviceConfiguration other = deviceRepo.findByDeviceId(deviceId); + if (other != null && !other.getId().equals(device.getId())) { + throw new IllegalArgumentException("A device with Device ID " + deviceId + " already exists"); + } + device.setDeviceId(deviceId); + } + if (trimToNull(request.getDeviceName()) != null) { + device.setDeviceName(NikshayDeviceName.from(request.getDeviceName())); + } + if (trimToNull(request.getPhiUserId()) != null) { + device.setPhiUserId(request.getPhiUserId().trim()); + } + + if (trimToNull(request.getPassword()) != null) { + device.setPassword(passwordCipher.encrypt(request.getPassword().trim())); + } + if (request.getMoreData() != null) { + device.setMoreData(toJson(request.getMoreData())); + } + device.setUpdatedBy(updatedBy); + device = deviceRepo.save(device); + logger.info("Nikshay device {} updated by {}", device.getDeviceId(), updatedBy); + return toResponse(device); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public NikshayDeviceResponse setDeviceActive(NikshayActivateRequest request) throws Exception { + require(request, "Request body is required"); + NikshayDeviceConfiguration device = findDevice(require(request.getId(), "Device id is required")); + Boolean active = require(request.getIsActive(), "isActive is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + + device.setIsActive(active); + device.setUpdatedBy(updatedBy); + device = deviceRepo.save(device); + // An inactive device must not keep its villages, otherwise those villages + // would look covered while the job ignores the device. On re-activation + // the villages have to be enrolled again. + if (!active) { + int released = mappingRepo.deactivateAllForDevice(device.getId(), updatedBy); + logger.info("Nikshay device {} deactivated by {}, {} village mapping(s) released", device.getDeviceId(), + updatedBy, released); + } + return toResponse(device); + } + + @Override + public List listDevices(String deviceName, Boolean isActive) throws Exception { + NikshayDeviceName name = trimToNull(deviceName) == null ? null : NikshayDeviceName.from(deviceName); + List result = new ArrayList<>(); + for (NikshayDeviceConfiguration device : deviceRepo.search(name, isActive)) { + result.add(toResponse(device)); + } + return result; + } + + @Override + public List searchByDeviceId(String deviceId) throws Exception { + String term = require(deviceId, "Device ID is required"); + List result = new ArrayList<>(); + for (NikshayDeviceConfiguration device : deviceRepo.searchByDeviceId(term)) { + NikshayDeviceResponse response = toResponse(device); + response.setVillages(getVillagesOfDevice(device.getId(), true)); + result.add(response); + } + return result; + } + + @Override + public NikshayDeviceResponse getDevice(Integer id) throws Exception { + NikshayDeviceResponse response = toResponse(findDevice(require(id, "Device id is required"))); + response.setVillages(getVillagesOfDevice(id, true)); + return response; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map enrollVillages(NikshayVillageEnrollRequest request) throws Exception { + require(request, "Request body is required"); + NikshayDeviceConfiguration device = findDevice(require(request.getDeviceDetailId(), "Device id is required")); + String createdBy = require(request.getCreatedBy(), "Created by is required"); + return enroll(device, request.getVillages(), createdBy); + } + + /** + * Maps an active device to villages. Villages already mapped to this device + * are skipped; the whole call is rejected if any village is actively mapped + * to another device of the same type (a village has one NAAT and one XRAY). + */ + private Map enroll(NikshayDeviceConfiguration device, List villages, + String createdBy) { + if (!Boolean.TRUE.equals(device.getIsActive())) { + throw new IllegalArgumentException("Device " + device.getDeviceId() + " is inactive"); + } + if (villages == null || villages.isEmpty()) { + throw new IllegalArgumentException("At least one village is required"); + } + + // One entry per village; the first occurrence wins + Map byVillage = new LinkedHashMap<>(); + for (NikshayVillageEnrollRequest.Village v : villages) { + require(v, "Village entry is required"); + require(v.getStateId(), "State is required"); + require(v.getDistrictId(), "District is required"); + require(v.getTbuId(), "TBU is required"); + require(v.getFacilityId(), "Facility is required"); + byVillage.putIfAbsent(require(v.getVillageId(), "Village is required"), v); + } + List villageIds = new ArrayList<>(byVillage.keySet()); + + Set known = new HashSet<>(); + for (NikshayVillage v : nikshayVillageRepo.findAllById(villageIds)) { + if (!Boolean.TRUE.equals(v.getDeleted())) { + known.add(v.getNikshayVillageID()); + } + } + List unknown = villageIds.stream().filter(id -> !known.contains(id)).collect(Collectors.toList()); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("Ni-kshay village(s) not found: " + unknown); + } + + Set alreadyMapped = new HashSet<>(); + List mappedToOther = new ArrayList<>(); + for (NikshayDeviceVillageMapping m : mappingRepo.findActiveByVillageIdsAndDeviceName(villageIds, + device.getDeviceName())) { + if (m.getDeviceDetailId().equals(device.getId())) { + alreadyMapped.add(m.getVillageId()); + } else { + mappedToOther.add(m.getVillageId()); + } + } + if (!mappedToOther.isEmpty()) { + throw new IllegalArgumentException("Village(s) " + mappedToOther + " already have an active " + + device.getDeviceName() + " device. Remove them from that device first."); + } + + List toSave = new ArrayList<>(); + for (Map.Entry e : byVillage.entrySet()) { + if (alreadyMapped.contains(e.getKey())) { + continue; + } + NikshayVillageEnrollRequest.Village v = e.getValue(); + NikshayDeviceVillageMapping m = new NikshayDeviceVillageMapping(); + m.setDeviceDetailId(device.getId()); + m.setStateId(v.getStateId()); + m.setDistrictId(v.getDistrictId()); + m.setTbuId(v.getTbuId()); + m.setFacilityId(v.getFacilityId()); + m.setVillageId(e.getKey()); + m.setIsActive(true); + m.setMoreData(toJson(v.getMoreData())); + m.setCreatedBy(createdBy); + toSave.add(m); + } + mappingRepo.saveAll(toSave); + logger.info("Nikshay device {} enrolled for {} village(s) by {}", device.getDeviceId(), toSave.size(), createdBy); + + Map result = new LinkedHashMap<>(); + result.put("deviceDetailId", device.getId()); + result.put("enrolledCount", toSave.size()); + result.put("alreadyMappedVillageIds", new ArrayList<>(alreadyMapped)); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map removeVillages(NikshayVillageRemoveRequest request) throws Exception { + require(request, "Request body is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + if (request.getMappingIds() == null || request.getMappingIds().isEmpty()) { + throw new IllegalArgumentException("At least one mapping id is required"); + } + List ids = request.getMappingIds().stream().distinct().collect(Collectors.toList()); + List rows = new ArrayList<>(); + mappingRepo.findAllById(ids).forEach(rows::add); + if (rows.size() != ids.size()) { + Set found = rows.stream().map(NikshayDeviceVillageMapping::getId).collect(Collectors.toSet()); + throw new IllegalArgumentException("Mapping(s) not found: " + + ids.stream().filter(id -> !found.contains(id)).collect(Collectors.toList())); + } + int removed = 0; + for (NikshayDeviceVillageMapping m : rows) { + if (Boolean.TRUE.equals(m.getIsActive())) { + m.setIsActive(false); + m.setUpdatedBy(updatedBy); + removed++; + } + } + mappingRepo.saveAll(rows); + + Map result = new LinkedHashMap<>(); + result.put("removedCount", removed); + return result; + } + + @Override + public List> getVillagesOfDevice(Integer deviceDetailId, Boolean activeOnly) throws Exception { + require(deviceDetailId, "Device id is required"); + List> result = new ArrayList<>(); + // row layout: see NikshayDeviceVillageMappingRepo.findVillagesOfDevice + for (Object[] row : mappingRepo.findVillagesOfDevice(deviceDetailId, !Boolean.FALSE.equals(activeOnly))) { + Map m = new LinkedHashMap<>(); + m.put("mappingId", toInt(row[0])); + m.put("villageId", toInt(row[1])); + m.put("villageName", row[2]); + m.put("facilityId", toInt(row[3])); + m.put("facilityName", row[4]); + m.put("tbuId", toInt(row[5])); + m.put("tbuName", row[6]); + m.put("districtId", toInt(row[7])); + m.put("stateId", toInt(row[8])); + m.put("isActive", toBool(row[9])); + m.put("createdBy", row[10]); + m.put("createdAt", str(row[11])); + result.add(m); + } + return result; + } + + @Override + public List> getPhiByLocation(Integer stateId, Integer districtId, Integer tbuId, + Integer facilityId, Integer villageId, String deviceName) throws Exception { + if (stateId == null && districtId == null && tbuId == null && facilityId == null && villageId == null) { + throw new IllegalArgumentException("At least one of state, district, TBU, facility or village is required"); + } + String name = trimToNull(deviceName) == null ? null : NikshayDeviceName.from(deviceName).name(); + List> result = new ArrayList<>(); + // row layout: see NikshayDeviceVillageMappingRepo.findPhiByLocation + for (Object[] row : mappingRepo.findPhiByLocation(stateId, districtId, tbuId, facilityId, villageId, name)) { + Map m = new LinkedHashMap<>(); + m.put("mappingId", toInt(row[0])); + m.put("villageId", toInt(row[1])); + m.put("villageName", row[2]); + m.put("facilityId", toInt(row[3])); + m.put("tbuId", toInt(row[4])); + m.put("districtId", toInt(row[5])); + m.put("stateId", toInt(row[6])); + m.put("deviceDetailId", toInt(row[7])); + m.put("deviceId", row[8]); + m.put("deviceName", row[9]); + m.put("phiUserId", row[10]); + result.add(m); + } + return result; + } + + private NikshayDeviceConfiguration findDevice(Integer id) { + return deviceRepo.findById(id) + .orElseThrow(() -> new IllegalArgumentException("No device found with id " + id)); + } + + private NikshayDeviceResponse toResponse(NikshayDeviceConfiguration d) { + NikshayDeviceResponse r = new NikshayDeviceResponse(); + r.setId(d.getId()); + r.setDeviceId(d.getDeviceId()); + r.setDeviceName(d.getDeviceName()); + r.setPhiUserId(d.getPhiUserId()); + r.setIsActive(d.getIsActive()); + r.setMoreData(fromJson(d.getMoreData())); + r.setActiveVillageCount(d.getId() == null ? 0L : mappingRepo.countActiveByDevice(d.getId())); + r.setCreatedBy(d.getCreatedBy()); + r.setCreatedAt(str(d.getCreatedAt())); + r.setUpdatedBy(d.getUpdatedBy()); + r.setUpdatedAt(str(d.getUpdatedAt())); + return r; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayRowUtils.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayRowUtils.java new file mode 100644 index 00000000..9a221117 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayRowUtils.java @@ -0,0 +1,78 @@ +package com.iemr.admin.service.nikshay; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +/** + * Small helpers shared by the Ni-kshay device / designated PHI services for + * request trimming, more_data JSON and reading native query rows (whose + * column types vary by driver: BIT -> Boolean, TINYINT -> Number, etc.). + */ +final class NikshayRowUtils { + + private static final Gson GSON = new Gson(); + + private NikshayRowUtils() { + } + + static String trimToNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + static String require(String value, String message) { + String t = trimToNull(value); + if (t == null) { + throw new IllegalArgumentException(message); + } + return t; + } + + static T require(T value, String message) { + if (value == null) { + throw new IllegalArgumentException(message); + } + return value; + } + + // moreData arrives as any JSON value; stored as a JSON string + static String toJson(Object moreData) { + return moreData == null ? null : GSON.toJson(moreData); + } + + static Object fromJson(String moreData) { + if (moreData == null || moreData.isBlank()) { + return null; + } + try { + return GSON.fromJson(JsonParser.parseString(moreData), Object.class); + } catch (JsonParseException e) { + return moreData; + } + } + + static String str(Object o) { + return o == null ? null : o.toString(); + } + + static Integer toInt(Object o) { + return o == null ? null : ((Number) o).intValue(); + } + + static Boolean toBool(Object o) { + if (o == null) { + return null; + } + if (o instanceof Boolean) { + return (Boolean) o; + } + if (o instanceof Number) { + return ((Number) o).intValue() != 0; + } + return Boolean.parseBoolean(o.toString()); + } +} diff --git a/src/main/java/com/iemr/admin/utils/crypto/NikshayPasswordCipher.java b/src/main/java/com/iemr/admin/utils/crypto/NikshayPasswordCipher.java new file mode 100644 index 00000000..c8a4e015 --- /dev/null +++ b/src/main/java/com/iemr/admin/utils/crypto/NikshayPasswordCipher.java @@ -0,0 +1,109 @@ +package com.iemr.admin.utils.crypto; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + +/** + * Encrypts the Ni-kshay PHI password of a camp device before it is stored in + * phir_device_configuration.password. + * + * Format (MMU-API's Ni-kshay job must decrypt the same way, with the same key): + * AES/GCM/NoPadding, 12-byte random IV, 128-bit tag; stored value is + * Base64( IV || ciphertext+tag ). + * + * The key is Base64 of 16, 24 or 32 random bytes (e.g. `openssl rand -base64 32`), + * read from the NIKSHAY_DEVICE_AES_KEY environment variable through the + * nikshay.device.aes-key property. It is never logged. When it is missing or + * invalid the app still starts, but saving a device password fails with a + * clear error. + */ +@Component +public class NikshayPasswordCipher { + + private final Logger logger = LoggerFactory.getLogger(NikshayPasswordCipher.class); + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH_BITS = 128; + + private final SecureRandom secureRandom = new SecureRandom(); + + @Value("${nikshay.device.aes-key:}") + private String base64Key; + + private SecretKey key; + + @PostConstruct + void init() { + if (base64Key == null || base64Key.isBlank()) { + logger.warn("nikshay.device.aes-key (NIKSHAY_DEVICE_AES_KEY) is not set; Ni-kshay device passwords cannot be saved"); + return; + } + // A bad key only disables device password saving; it must not stop the + // rest of Admin-API from starting + byte[] raw; + try { + raw = Base64.getDecoder().decode(base64Key.trim()); + } catch (IllegalArgumentException e) { + logger.error("nikshay.device.aes-key is not valid Base64; Ni-kshay device passwords cannot be saved"); + return; + } + if (raw.length != 16 && raw.length != 24 && raw.length != 32) { + logger.error("nikshay.device.aes-key must decode to 16, 24 or 32 bytes; Ni-kshay device passwords cannot be saved"); + return; + } + key = new SecretKeySpec(raw, "AES"); + } + + public String encrypt(String plainText) { + SecretKey k = requireKey(); + try { + byte[] iv = new byte[IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, k, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder() + .encodeToString(ByteBuffer.allocate(iv.length + cipherText.length).put(iv).put(cipherText).array()); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Could not encrypt device password", e); + } + } + + // Not used by Admin-API itself; kept next to encrypt() so the format is + // defined in one place (MMU-API mirrors this). + public String decrypt(String stored) { + SecretKey k = requireKey(); + try { + byte[] all = Base64.getDecoder().decode(stored); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(TAG_LENGTH_BITS, all, 0, IV_LENGTH)); + return new String(cipher.doFinal(all, IV_LENGTH, all.length - IV_LENGTH), StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new IllegalStateException("Could not decrypt device password"); + } + } + + private SecretKey requireKey() { + if (key == null) { + throw new IllegalStateException( + "Device password encryption key is not configured (NIKSHAY_DEVICE_AES_KEY). Contact the administrator."); + } + return key; + } +} From 87e1c3f0a1b3afc2701824262f29fb83d6cec6f1 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Mon, 26 Oct 2026 17:29:18 +0530 Subject: [PATCH 2/4] stop:647 Nikshay integration's get device and designated PHI enhancement. --- .../nikshay/NikshayDesignatedPhiController.java | 14 ++++++++++++++ .../nikshay/NikshayDeviceController.java | 3 ++- .../nikshay/NikshayDesignatedPhiService.java | 2 ++ .../NikshayDesignatedPhiServiceImpl.java | 17 ++++++++++++++--- .../nikshay/NikshayDeviceServiceImpl.java | 4 +++- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java index a6ff8ab8..7c3e78ce 100644 --- a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java +++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java @@ -104,4 +104,18 @@ public String getDesignatedPhi(@RequestParam(value = "villageId", required = fal } return response.toStringWithSerialization(); } + + @Operation(summary = "Get all designated PHI mappings; activeOnly=true leaves out inactive rows") + @GetMapping(value = "/getAll", produces = "application/json", headers = "Authorization") + public String getAllDesignatedPhi( + @RequestParam(value = "activeOnly", required = false, defaultValue = "false") Boolean activeOnly) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(GSON.toJson(nikshayDesignatedPhiService.getAllDesignatedPhi(activeOnly))); + } catch (Exception e) { + logger.error("getAllDesignatedPhi failed: " + e.getMessage(), e); + response.setError(e); + } + return response.toStringWithSerialization(); + } } diff --git a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java index 2a9f9eac..e5e1d3dc 100644 --- a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java +++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java @@ -54,7 +54,8 @@ public String setDeviceActive(@RequestBody NikshayActivateRequest request) { return respond("setDeviceActive", () -> nikshayDeviceService.setDeviceActive(request)); } - @Operation(summary = "List devices (without password), optionally by device name (NAAT / XRAY) and active flag") + @Operation(summary = "List devices (without password), each with its active villages, " + + "optionally by device name (NAAT / XRAY) and active flag") @GetMapping(value = "/list", produces = "application/json", headers = "Authorization") public String listDevices(@RequestParam(value = "deviceName", required = false) String deviceName, @RequestParam(value = "isActive", required = false) Boolean isActive) { diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java index 453d2dfe..f8fa31ea 100644 --- a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java @@ -18,4 +18,6 @@ public interface NikshayDesignatedPhiService { List> getDesignatedPhi(Integer villageId, Integer tbuId, Integer districtId, Boolean activeOnly, Boolean unmappedOnly) throws Exception; + + List> getAllDesignatedPhi(Boolean activeOnly) throws Exception; } diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java index 81d83f74..c0aad178 100644 --- a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java @@ -153,8 +153,8 @@ public List> getDesignatedPhi(Integer villageId, Integer tbu if (villageId == null && tbuId == null && districtId == null) { throw new IllegalArgumentException("Village, TBU or district is required"); } - List> result = new ArrayList<>(); if (Boolean.TRUE.equals(unmappedOnly)) { + List> result = new ArrayList<>(); // row layout: see NikshayTransferOutPhiRepo.findVillagesWithoutDesignatedPhi for (Object[] row : transferOutPhiRepo.findVillagesWithoutDesignatedPhi(villageId, tbuId, districtId)) { Map m = new LinkedHashMap<>(); @@ -168,8 +168,19 @@ public List> getDesignatedPhi(Integer villageId, Integer tbu } return result; } - // row layout: see NikshayTransferOutPhiRepo.search - for (Object[] row : transferOutPhiRepo.search(villageId, tbuId, districtId, !Boolean.FALSE.equals(activeOnly))) { + return toSearchRows(transferOutPhiRepo.search(villageId, tbuId, districtId, !Boolean.FALSE.equals(activeOnly))); + } + + // Every designated PHI mapping, with no village / TBU / district filter + @Override + public List> getAllDesignatedPhi(Boolean activeOnly) throws Exception { + return toSearchRows(transferOutPhiRepo.search(null, null, null, Boolean.TRUE.equals(activeOnly))); + } + + // row layout: see NikshayTransferOutPhiRepo.search + private List> toSearchRows(List rows) { + List> result = new ArrayList<>(); + for (Object[] row : rows) { Map m = new LinkedHashMap<>(); m.put("id", toInt(row[0])); m.put("villageId", toInt(row[1])); diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java index b4300342..2ca5d7de 100644 --- a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java @@ -145,7 +145,9 @@ public List listDevices(String deviceName, Boolean isActi NikshayDeviceName name = trimToNull(deviceName) == null ? null : NikshayDeviceName.from(deviceName); List result = new ArrayList<>(); for (NikshayDeviceConfiguration device : deviceRepo.search(name, isActive)) { - result.add(toResponse(device)); + NikshayDeviceResponse response = toResponse(device); + response.setVillages(getVillagesOfDevice(device.getId(), true)); + result.add(response); } return result; } From f2e4651649c9504356c29bd2fd130b18b241df88 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Wed, 28 Oct 2026 13:24:50 +0530 Subject: [PATCH 3/4] stop:647 Nikshay integration's get device and designated PHI feedback enhancement. --- .../nikshay/NikshayDeviceController.java | 44 +++- .../nikshay/NikshayDeviceConfiguration.java | 4 +- .../model/nikshay/NikshayDeviceRequest.java | 4 +- .../model/nikshay/NikshayDeviceResponse.java | 2 +- .../nikshay/NikshayVillageEnrollRequest.java | 24 +- .../NikshayDeviceConfigurationRepo.java | 10 +- .../NikshayDeviceVillageMappingRepo.java | 5 +- .../NikshayDesignatedPhiServiceImpl.java | 7 + .../service/nikshay/NikshayDeviceExcel.java | 143 ++++++++++++ .../service/nikshay/NikshayDeviceService.java | 7 +- .../nikshay/NikshayDeviceServiceImpl.java | 206 +++++++++++++----- 11 files changed, 376 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceExcel.java diff --git a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java index e5e1d3dc..1abeadc8 100644 --- a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java +++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java @@ -3,12 +3,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -33,6 +38,9 @@ public class NikshayDeviceController { private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + private static final String UPLOAD_TEMPLATE_NAME = "Nikshay_Device_Upload_Template.xlsx"; + private static final String XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + @Autowired private NikshayDeviceService nikshayDeviceService; @@ -42,6 +50,33 @@ public String createDevice(@RequestBody NikshayDeviceRequest request) { return respond("createDevice", () -> nikshayDeviceService.createDevice(request)); } + @Operation(summary = "Download the Excel template for the bulk device upload") + @GetMapping(value = "/upload/template", headers = "Authorization") + public ResponseEntity downloadUploadTemplate() { + try { + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(UPLOAD_TEMPLATE_NAME).build().toString()) + .contentType(MediaType.parseMediaType(XLSX)) + .body(nikshayDeviceService.getUploadTemplate()); + } catch (Exception e) { + logger.error("downloadUploadTemplate failed: " + e.getMessage(), e); + return ResponseEntity.internalServerError().build(); + } + } + + @Operation(summary = "Create devices from the filled Excel template; nothing is created if any row is invalid") + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = "application/json", + headers = "Authorization") + public String uploadDevices(@RequestParam("file") MultipartFile file, @RequestParam("createdBy") String createdBy) { + return respond("uploadDevices", () -> { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Excel file is required"); + } + return nikshayDeviceService.uploadDevices(file.getInputStream(), createdBy); + }); + } + @Operation(summary = "Update a device; the password changes only when a new one is sent") @PostMapping(value = "/update", produces = "application/json", headers = "Authorization") public String updateDevice(@RequestBody NikshayDeviceRequest request) { @@ -62,11 +97,11 @@ public String listDevices(@RequestParam(value = "deviceName", required = false) return respond("listDevices", () -> nikshayDeviceService.listDevices(deviceName, isActive)); } - @Operation(summary = "Search devices by device ID (partial, case-insensitive; exact match first), " + @Operation(summary = "Search devices by serial number (partial, case-insensitive; exact match first), " + "without password, each with its active villages") @GetMapping(value = "/search", produces = "application/json", headers = "Authorization") - public String searchByDeviceId(@RequestParam("deviceId") String deviceId) { - return respond("searchByDeviceId", () -> nikshayDeviceService.searchByDeviceId(deviceId)); + public String searchByDeviceSerialNumber(@RequestParam("deviceSerialNumber") String deviceSerialNumber) { + return respond("searchByDeviceSerialNumber", () -> nikshayDeviceService.searchByDeviceSerialNumber(deviceSerialNumber)); } @Operation(summary = "Get one device (without password) with its active villages") @@ -75,7 +110,8 @@ public String getDevice(@RequestParam("id") Integer id) { return respond("getDevice", () -> nikshayDeviceService.getDevice(id)); } - @Operation(summary = "Enroll a device for one or more Ni-kshay villages. A village can have one active NAAT and one active XRAY device") + @Operation(summary = "Enroll a device for villages picked from one or more facilities of a TBU (facilityIds, villageIds). " + + "A village can have one active NAAT and one active XRAY device") @PostMapping(value = "/village/enroll", produces = "application/json", headers = "Authorization") public String enrollVillages(@RequestBody NikshayVillageEnrollRequest request) { return respond("enrollVillages", () -> nikshayDeviceService.enrollVillages(request)); diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java index fcf2275e..10f49db6 100644 --- a/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java @@ -37,8 +37,8 @@ public class NikshayDeviceConfiguration { private Integer id; @Expose - @Column(name = "device_id", unique = true) - private String deviceId; + @Column(name = "device_serial_number", unique = true) + private String deviceSerialNumber; @Expose @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java index 89d476ff..2f9e0338 100644 --- a/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java @@ -12,7 +12,7 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class NikshayDeviceRequest { private Integer id; - private String deviceId; + private String deviceSerialNumber; private String deviceName; private String phiUserId; private String password; @@ -22,6 +22,6 @@ public class NikshayDeviceRequest { @Override public String toString() { - return "NikshayDeviceRequest(id=" + id + ", deviceId=" + deviceId + ", deviceName=" + deviceName + ")"; + return "NikshayDeviceRequest(id=" + id + ", deviceSerialNumber=" + deviceSerialNumber + ", deviceName=" + deviceName + ")"; } } diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java index 55fe724a..3c9ec91c 100644 --- a/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java @@ -11,7 +11,7 @@ @Data public class NikshayDeviceResponse { private Integer id; - private String deviceId; + private String deviceSerialNumber; private NikshayDeviceName deviceName; private String phiUserId; private Boolean isActive; diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java index 0207f927..3d91e604 100644 --- a/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java @@ -6,22 +6,20 @@ import lombok.Data; -// Enroll a device (phir_device_configuration.id) for one or more Ni-kshay villages +/** + * Enroll a device (m_nikshay_device_configuration.id) for villages picked from + * one or more facilities of a TBU. Each village is saved under its own facility + * (m_nikshay_village.NikshayFacilityID), which must be one of facilityIds. + */ @Data @JsonIgnoreProperties(ignoreUnknown = true) public class NikshayVillageEnrollRequest { private Integer deviceDetailId; + private Integer stateId; + private Integer districtId; + private Integer tbuId; + private List facilityIds; + private List villageIds; + private Object moreData; private String createdBy; - private List villages; - - @Data - @JsonIgnoreProperties(ignoreUnknown = true) - public static class Village { - private Integer stateId; - private Integer districtId; - private Integer tbuId; - private Integer facilityId; - private Integer villageId; - private Object moreData; - } } diff --git a/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java index 46b7154c..d086380f 100644 --- a/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java +++ b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java @@ -13,12 +13,12 @@ @Repository public interface NikshayDeviceConfigurationRepo extends CrudRepository { - NikshayDeviceConfiguration findByDeviceId(String deviceId); + NikshayDeviceConfiguration findByDeviceSerialNumber(String deviceSerialNumber); - // Partial, case-insensitive match on the programme device ID; exact matches first - @Query("select d from NikshayDeviceConfiguration d where lower(d.deviceId) like lower(concat('%', :deviceId, '%')) " - + "order by case when lower(d.deviceId) = lower(:deviceId) then 0 else 1 end, d.deviceId") - List searchByDeviceId(@Param("deviceId") String deviceId); + // Partial, case-insensitive match on the device serial number; exact matches first + @Query("select d from NikshayDeviceConfiguration d where lower(d.deviceSerialNumber) like lower(concat('%', :deviceSerialNumber, '%')) " + + "order by case when lower(d.deviceSerialNumber) = lower(:deviceSerialNumber) then 0 else 1 end, d.deviceSerialNumber") + List searchByDeviceSerialNumber(@Param("deviceSerialNumber") String deviceSerialNumber); @Query("select d from NikshayDeviceConfiguration d where (:deviceName is null or d.deviceName = :deviceName) " + "and (:isActive is null or d.isActive = :isActive) order by d.deviceName, d.id") diff --git a/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java index 4b0a22b6..fece3f02 100644 --- a/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java +++ b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java @@ -43,12 +43,13 @@ List findVillagesOfDevice(@Param("deviceDetailId") Integer deviceDetai // Active mappings of active devices for a location. row = [mapping id, village_id, // VillageName, facility_id, tbu_id, district_id, state_id, device pk id, - // device_id, device_name, phi_user_id]. Never selects the password. + // device_serial_number, device_name, phi_user_id, TUName]. Never selects the password. @Query(value = "select m.id, m.village_id, v.VillageName, m.facility_id, m.tbu_id, m.district_id, m.state_id, " - + "d.id as device_detail_id, d.device_id, d.device_name, d.phi_user_id " + + "d.id as device_detail_id, d.device_serial_number, d.device_name, d.phi_user_id, t.TUName " + "from m_nikshay_device_village_mapping m " + "join m_nikshay_device_configuration d on d.id = m.device_detail_id " + "left join m_nikshay_village v on v.NikshayVillageID = m.village_id " + + "left join m_nikshay_tu t on t.NikshayTUID = m.tbu_id " + "where m.is_active = true and d.is_active = true " + "and (:stateId is null or m.state_id = :stateId) " + "and (:districtId is null or m.district_id = :districtId) " diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java index c0aad178..cb87c41e 100644 --- a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java @@ -16,9 +16,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.iemr.admin.data.nikshay.NikshayTU; import com.iemr.admin.data.nikshay.NikshayTransferOutPhiVillageMapping; import com.iemr.admin.model.nikshay.NikshayActivateRequest; import com.iemr.admin.model.nikshay.NikshayDesignatedPhiRequest; +import com.iemr.admin.repo.nikshay.NikshayTURepo; import com.iemr.admin.repo.nikshay.NikshayTransferOutPhiRepo; import com.iemr.admin.repo.nikshay.NikshayVillageRepo; @@ -37,6 +39,9 @@ public class NikshayDesignatedPhiServiceImpl implements NikshayDesignatedPhiServ @Autowired private NikshayVillageRepo nikshayVillageRepo; + @Autowired + private NikshayTURepo nikshayTURepo; + @Override @Transactional(rollbackFor = Exception.class) public Map saveDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception { @@ -221,6 +226,8 @@ private Map toMap(NikshayTransferOutPhiVillageMapping p) { m.put("id", p.getId()); m.put("villageId", p.getVillageId()); m.put("tbuId", p.getTbuId()); + m.put("tbuName", p.getTbuId() == null ? null + : nikshayTURepo.findById(p.getTbuId()).map(NikshayTU::getTUName).orElse(null)); m.put("designatedPhiId", p.getDesignatedPhiId()); m.put("designationHierarchyId", p.getDesignationHierarchyId()); m.put("status", p.getStatus()); diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceExcel.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceExcel.java new file mode 100644 index 00000000..28228d81 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceExcel.java @@ -0,0 +1,143 @@ +package com.iemr.admin.service.nikshay; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DataValidation; +import org.apache.poi.ss.usermodel.DataValidationHelper; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.ss.util.CellRangeAddressList; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +import com.iemr.admin.data.nikshay.NikshayDeviceName; +import com.iemr.admin.model.nikshay.NikshayDeviceRequest; + +/** + * Excel template and reader for the bulk device upload. The template has one + * header row; columns are read by position, so the column order here is the + * contract between the downloaded template and the upload. + */ +final class NikshayDeviceExcel { + + static final int MAX_ROWS = 1000; + + private static final String SHEET_NAME = "Devices"; + private static final String[] HEADERS = { "Device Serial Number*", "Device Name* (NAAT / XRAY)", + "PHI User ID*", "Password*" }; + private static final int COL_SERIAL = 0; + private static final int COL_NAME = 1; + private static final int COL_PHI_USER = 2; + private static final int COL_PASSWORD = 3; + + private NikshayDeviceExcel() { + } + + /** One data row of the upload; rowNumber is the Excel row number (header is row 1). */ + static class DeviceRow { + final int rowNumber; + final NikshayDeviceRequest request; + + DeviceRow(int rowNumber, NikshayDeviceRequest request) { + this.rowNumber = rowNumber; + this.request = request; + } + } + + static byte[] template() throws IOException { + try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + Sheet sheet = wb.createSheet(SHEET_NAME); + CellStyle headerStyle = wb.createCellStyle(); + Font bold = wb.createFont(); + bold.setBold(true); + headerStyle.setFont(bold); + + Row header = sheet.createRow(0); + for (int i = 0; i < HEADERS.length; i++) { + Cell cell = header.createCell(i); + cell.setCellValue(HEADERS[i]); + cell.setCellStyle(headerStyle); + sheet.setColumnWidth(i, 28 * 256); + } + sheet.createFreezePane(0, 1); + + // Serial numbers are text; keeps Excel from turning long numbers into 1.23E+11 + CellStyle text = wb.createCellStyle(); + text.setDataFormat(wb.createDataFormat().getFormat("@")); + sheet.setDefaultColumnStyle(COL_SERIAL, text); + sheet.setDefaultColumnStyle(COL_PHI_USER, text); + sheet.setDefaultColumnStyle(COL_PASSWORD, text); + + // Device name dropdown + DataValidationHelper helper = sheet.getDataValidationHelper(); + String[] names = new String[NikshayDeviceName.values().length]; + for (int i = 0; i < names.length; i++) { + names[i] = NikshayDeviceName.values()[i].name(); + } + DataValidation validation = helper.createValidation(helper.createExplicitListConstraint(names), + new CellRangeAddressList(1, MAX_ROWS, COL_NAME, COL_NAME)); + validation.setShowErrorBox(true); + validation.createErrorBox("Invalid device name", "Choose NAAT or XRAY"); + sheet.addValidationData(validation); + + wb.write(out); + return out.toByteArray(); + } + } + + /** Reads the first sheet; fully blank rows are skipped. Values are trimmed, blanks become null. */ + static List read(InputStream in) throws IOException { + List rows = new ArrayList<>(); + DataFormatter formatter = new DataFormatter(); + try (Workbook wb = WorkbookFactory.create(in)) { + Sheet sheet = wb.getSheetAt(0); + for (int r = 1; r <= sheet.getLastRowNum(); r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + String serial = cell(formatter, row, COL_SERIAL); + String name = cell(formatter, row, COL_NAME); + String phiUser = cell(formatter, row, COL_PHI_USER); + String password = cell(formatter, row, COL_PASSWORD); + if (serial == null && name == null && phiUser == null && password == null) { + continue; + } + if (rows.size() == MAX_ROWS) { + throw new IllegalArgumentException("At most " + MAX_ROWS + " devices can be uploaded at a time"); + } + NikshayDeviceRequest request = new NikshayDeviceRequest(); + request.setDeviceSerialNumber(serial); + request.setDeviceName(name); + request.setPhiUserId(phiUser); + request.setPassword(password); + rows.add(new DeviceRow(r + 1, request)); + } + } + return rows; + } + + private static String cell(DataFormatter formatter, Row row, int col) { + Cell cell = row.getCell(col, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); + if (cell == null) { + return null; + } + // A serial number typed into a number cell would otherwise come back as 1.23457E+11 + if (cell.getCellType() == CellType.NUMERIC && !DateUtil.isCellDateFormatted(cell)) { + return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString(); + } + return NikshayRowUtils.trimToNull(formatter.formatCellValue(cell)); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java index 03b9b952..1571b88c 100644 --- a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java @@ -1,5 +1,6 @@ package com.iemr.admin.service.nikshay; +import java.io.InputStream; import java.util.List; import java.util.Map; @@ -13,6 +14,10 @@ public interface NikshayDeviceService { NikshayDeviceResponse createDevice(NikshayDeviceRequest request) throws Exception; + byte[] getUploadTemplate() throws Exception; + + Map uploadDevices(InputStream excel, String createdBy) throws Exception; + NikshayDeviceResponse updateDevice(NikshayDeviceRequest request) throws Exception; NikshayDeviceResponse setDeviceActive(NikshayActivateRequest request) throws Exception; @@ -21,7 +26,7 @@ public interface NikshayDeviceService { NikshayDeviceResponse getDevice(Integer id) throws Exception; - List searchByDeviceId(String deviceId) throws Exception; + List searchByDeviceSerialNumber(String deviceSerialNumber) throws Exception; Map enrollVillages(NikshayVillageEnrollRequest request) throws Exception; diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java index 2ca5d7de..6ad79301 100644 --- a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java @@ -8,11 +8,15 @@ import static com.iemr.admin.service.nikshay.NikshayRowUtils.toJson; import static com.iemr.admin.service.nikshay.NikshayRowUtils.trimToNull; +import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -25,6 +29,7 @@ import com.iemr.admin.data.nikshay.NikshayDeviceConfiguration; import com.iemr.admin.data.nikshay.NikshayDeviceName; import com.iemr.admin.data.nikshay.NikshayDeviceVillageMapping; +import com.iemr.admin.data.nikshay.NikshayFacility; import com.iemr.admin.data.nikshay.NikshayVillage; import com.iemr.admin.model.nikshay.NikshayActivateRequest; import com.iemr.admin.model.nikshay.NikshayDeviceRequest; @@ -33,6 +38,7 @@ import com.iemr.admin.model.nikshay.NikshayVillageRemoveRequest; import com.iemr.admin.repo.nikshay.NikshayDeviceConfigurationRepo; import com.iemr.admin.repo.nikshay.NikshayDeviceVillageMappingRepo; +import com.iemr.admin.repo.nikshay.NikshayFacilityRepo; import com.iemr.admin.repo.nikshay.NikshayVillageRepo; import com.iemr.admin.utils.crypto.NikshayPasswordCipher; @@ -56,6 +62,9 @@ public class NikshayDeviceServiceImpl implements NikshayDeviceService { @Autowired private NikshayVillageRepo nikshayVillageRepo; + @Autowired + private NikshayFacilityRepo nikshayFacilityRepo; + @Autowired private NikshayPasswordCipher passwordCipher; @@ -63,16 +72,16 @@ public class NikshayDeviceServiceImpl implements NikshayDeviceService { @Transactional(rollbackFor = Exception.class) public NikshayDeviceResponse createDevice(NikshayDeviceRequest request) throws Exception { require(request, "Request body is required"); - String deviceId = require(request.getDeviceId(), "Device ID is required"); + String deviceSerialNumber = require(request.getDeviceSerialNumber(), "Device serial number is required"); String password = require(request.getPassword(), "Password is required"); String createdBy = require(request.getCreatedBy(), "Created by is required"); - if (deviceRepo.findByDeviceId(deviceId) != null) { - throw new IllegalArgumentException("A device with Device ID " + deviceId + " already exists"); + if (deviceRepo.findByDeviceSerialNumber(deviceSerialNumber) != null) { + throw new IllegalArgumentException("A device with serial number " + deviceSerialNumber + " already exists"); } NikshayDeviceConfiguration device = new NikshayDeviceConfiguration(); - device.setDeviceId(deviceId); + device.setDeviceSerialNumber(deviceSerialNumber); device.setDeviceName(NikshayDeviceName.from(request.getDeviceName())); device.setPhiUserId(require(request.getPhiUserId(), "PHI user ID is required")); device.setPassword(passwordCipher.encrypt(password)); @@ -80,10 +89,80 @@ public NikshayDeviceResponse createDevice(NikshayDeviceRequest request) throws E device.setMoreData(toJson(request.getMoreData())); device.setCreatedBy(createdBy); device = deviceRepo.save(device); - logger.info("Nikshay device {} created by {}", deviceId, createdBy); + logger.info("Nikshay device {} created by {}", deviceSerialNumber, createdBy); return toResponse(device); } + @Override + public byte[] getUploadTemplate() throws Exception { + return NikshayDeviceExcel.template(); + } + + /** + * Creates the devices of an uploaded template. All rows are checked first and + * nothing is saved if any row is invalid; the error lists every bad row. + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map uploadDevices(InputStream excel, String createdBy) throws Exception { + String uploadedBy = require(createdBy, "Created by is required"); + List rows; + try { + rows = NikshayDeviceExcel.read(excel); + } catch (IOException | RuntimeException e) { + if (e instanceof IllegalArgumentException) { + throw e; + } + throw new IllegalArgumentException("Could not read the Excel file; upload the downloaded template (.xlsx)"); + } + if (rows.isEmpty()) { + throw new IllegalArgumentException("The Excel file has no device rows"); + } + + List errors = new ArrayList<>(); + Set seen = new HashSet<>(); + for (NikshayDeviceExcel.DeviceRow row : rows) { + NikshayDeviceRequest r = row.request; + List rowErrors = new ArrayList<>(); + if (r.getDeviceSerialNumber() == null) { + rowErrors.add("device serial number is required"); + } else if (!seen.add(r.getDeviceSerialNumber().toLowerCase())) { + rowErrors.add("serial number " + r.getDeviceSerialNumber() + " is repeated in the file"); + } else if (deviceRepo.findByDeviceSerialNumber(r.getDeviceSerialNumber()) != null) { + rowErrors.add("serial number " + r.getDeviceSerialNumber() + " already exists"); + } + try { + NikshayDeviceName.from(r.getDeviceName()); + } catch (IllegalArgumentException e) { + rowErrors.add(e.getMessage().toLowerCase()); + } + if (r.getPhiUserId() == null) { + rowErrors.add("PHI user ID is required"); + } + if (r.getPassword() == null) { + rowErrors.add("password is required"); + } + if (!rowErrors.isEmpty()) { + errors.add("Row " + row.rowNumber + ": " + String.join(", ", rowErrors)); + } + } + if (!errors.isEmpty()) { + throw new IllegalArgumentException("No devices created. " + String.join("; ", errors)); + } + + List created = new ArrayList<>(); + for (NikshayDeviceExcel.DeviceRow row : rows) { + row.request.setCreatedBy(uploadedBy); + created.add(createDevice(row.request)); + } + logger.info("{} Nikshay device(s) uploaded by {}", created.size(), uploadedBy); + + Map result = new LinkedHashMap<>(); + result.put("createdCount", created.size()); + result.put("devices", created); + return result; + } + @Override @Transactional(rollbackFor = Exception.class) public NikshayDeviceResponse updateDevice(NikshayDeviceRequest request) throws Exception { @@ -91,13 +170,13 @@ public NikshayDeviceResponse updateDevice(NikshayDeviceRequest request) throws E NikshayDeviceConfiguration device = findDevice(require(request.getId(), "Device id is required")); String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); - String deviceId = trimToNull(request.getDeviceId()); - if (deviceId != null && !deviceId.equals(device.getDeviceId())) { - NikshayDeviceConfiguration other = deviceRepo.findByDeviceId(deviceId); + String deviceSerialNumber = trimToNull(request.getDeviceSerialNumber()); + if (deviceSerialNumber != null && !deviceSerialNumber.equals(device.getDeviceSerialNumber())) { + NikshayDeviceConfiguration other = deviceRepo.findByDeviceSerialNumber(deviceSerialNumber); if (other != null && !other.getId().equals(device.getId())) { - throw new IllegalArgumentException("A device with Device ID " + deviceId + " already exists"); + throw new IllegalArgumentException("A device with serial number " + deviceSerialNumber + " already exists"); } - device.setDeviceId(deviceId); + device.setDeviceSerialNumber(deviceSerialNumber); } if (trimToNull(request.getDeviceName()) != null) { device.setDeviceName(NikshayDeviceName.from(request.getDeviceName())); @@ -114,7 +193,7 @@ public NikshayDeviceResponse updateDevice(NikshayDeviceRequest request) throws E } device.setUpdatedBy(updatedBy); device = deviceRepo.save(device); - logger.info("Nikshay device {} updated by {}", device.getDeviceId(), updatedBy); + logger.info("Nikshay device {} updated by {}", device.getDeviceSerialNumber(), updatedBy); return toResponse(device); } @@ -134,7 +213,7 @@ public NikshayDeviceResponse setDeviceActive(NikshayActivateRequest request) thr // the villages have to be enrolled again. if (!active) { int released = mappingRepo.deactivateAllForDevice(device.getId(), updatedBy); - logger.info("Nikshay device {} deactivated by {}, {} village mapping(s) released", device.getDeviceId(), + logger.info("Nikshay device {} deactivated by {}, {} village mapping(s) released", device.getDeviceSerialNumber(), updatedBy, released); } return toResponse(device); @@ -153,10 +232,10 @@ public List listDevices(String deviceName, Boolean isActi } @Override - public List searchByDeviceId(String deviceId) throws Exception { - String term = require(deviceId, "Device ID is required"); + public List searchByDeviceSerialNumber(String deviceSerialNumber) throws Exception { + String term = require(deviceSerialNumber, "Device serial number is required"); List result = new ArrayList<>(); - for (NikshayDeviceConfiguration device : deviceRepo.searchByDeviceId(term)) { + for (NikshayDeviceConfiguration device : deviceRepo.searchByDeviceSerialNumber(term)) { NikshayDeviceResponse response = toResponse(device); response.setVillages(getVillagesOfDevice(device.getId(), true)); result.add(response); @@ -177,45 +256,62 @@ public Map enrollVillages(NikshayVillageEnrollRequest request) t require(request, "Request body is required"); NikshayDeviceConfiguration device = findDevice(require(request.getDeviceDetailId(), "Device id is required")); String createdBy = require(request.getCreatedBy(), "Created by is required"); - return enroll(device, request.getVillages(), createdBy); + return enroll(device, request, createdBy); } /** - * Maps an active device to villages. Villages already mapped to this device - * are skipped; the whole call is rejected if any village is actively mapped - * to another device of the same type (a village has one NAAT and one XRAY). + * Maps an active device to the villages picked from one or more facilities + * of a TBU. Every facility must belong to the TBU and every village to one + * of the facilities; each mapping row keeps the village's own facility. + * Villages already mapped to this device are skipped; the whole call is + * rejected if any village is actively mapped to another device of the same + * type (a village has one NAAT and one XRAY). */ - private Map enroll(NikshayDeviceConfiguration device, List villages, + private Map enroll(NikshayDeviceConfiguration device, NikshayVillageEnrollRequest request, String createdBy) { if (!Boolean.TRUE.equals(device.getIsActive())) { - throw new IllegalArgumentException("Device " + device.getDeviceId() + " is inactive"); + throw new IllegalArgumentException("Device " + device.getDeviceSerialNumber() + " is inactive"); } - if (villages == null || villages.isEmpty()) { - throw new IllegalArgumentException("At least one village is required"); + Integer stateId = require(request.getStateId(), "State is required"); + Integer districtId = require(request.getDistrictId(), "District is required"); + Integer tbuId = require(request.getTbuId(), "TBU is required"); + List facilityIds = distinctIds(request.getFacilityIds(), "At least one facility is required"); + List villageIds = distinctIds(request.getVillageIds(), "At least one village is required"); + + Set knownFacilities = new HashSet<>(); + for (NikshayFacility f : nikshayFacilityRepo.findByIds(facilityIds)) { + if (!tbuId.equals(f.getNikshayTUID())) { + throw new IllegalArgumentException("Facility " + f.getNikshayFacilityID() + " does not belong to TBU " + tbuId); + } + knownFacilities.add(f.getNikshayFacilityID()); } - - // One entry per village; the first occurrence wins - Map byVillage = new LinkedHashMap<>(); - for (NikshayVillageEnrollRequest.Village v : villages) { - require(v, "Village entry is required"); - require(v.getStateId(), "State is required"); - require(v.getDistrictId(), "District is required"); - require(v.getTbuId(), "TBU is required"); - require(v.getFacilityId(), "Facility is required"); - byVillage.putIfAbsent(require(v.getVillageId(), "Village is required"), v); + List unknownFacilities = facilityIds.stream().filter(id -> !knownFacilities.contains(id)) + .collect(Collectors.toList()); + if (!unknownFacilities.isEmpty()) { + throw new IllegalArgumentException("Ni-kshay facility(s) not found: " + unknownFacilities); } - List villageIds = new ArrayList<>(byVillage.keySet()); - Set known = new HashSet<>(); + // village id -> its facility; the village must be under one of the selected facilities + Map facilityOfVillage = new HashMap<>(); + List outsideFacilities = new ArrayList<>(); for (NikshayVillage v : nikshayVillageRepo.findAllById(villageIds)) { - if (!Boolean.TRUE.equals(v.getDeleted())) { - known.add(v.getNikshayVillageID()); + if (Boolean.TRUE.equals(v.getDeleted())) { + continue; } + if (!knownFacilities.contains(v.getNikshayFacilityID())) { + outsideFacilities.add(v.getNikshayVillageID()); + } + facilityOfVillage.put(v.getNikshayVillageID(), v.getNikshayFacilityID()); } - List unknown = villageIds.stream().filter(id -> !known.contains(id)).collect(Collectors.toList()); + List unknown = villageIds.stream().filter(id -> !facilityOfVillage.containsKey(id)) + .collect(Collectors.toList()); if (!unknown.isEmpty()) { throw new IllegalArgumentException("Ni-kshay village(s) not found: " + unknown); } + if (!outsideFacilities.isEmpty()) { + throw new IllegalArgumentException( + "Village(s) " + outsideFacilities + " do not belong to the selected facilities " + facilityIds); + } Set alreadyMapped = new HashSet<>(); List mappedToOther = new ArrayList<>(); @@ -233,25 +329,24 @@ private Map enroll(NikshayDeviceConfiguration device, List toSave = new ArrayList<>(); - for (Map.Entry e : byVillage.entrySet()) { - if (alreadyMapped.contains(e.getKey())) { + for (Integer villageId : villageIds) { + if (alreadyMapped.contains(villageId)) { continue; } - NikshayVillageEnrollRequest.Village v = e.getValue(); NikshayDeviceVillageMapping m = new NikshayDeviceVillageMapping(); m.setDeviceDetailId(device.getId()); - m.setStateId(v.getStateId()); - m.setDistrictId(v.getDistrictId()); - m.setTbuId(v.getTbuId()); - m.setFacilityId(v.getFacilityId()); - m.setVillageId(e.getKey()); + m.setStateId(stateId); + m.setDistrictId(districtId); + m.setTbuId(tbuId); + m.setFacilityId(facilityOfVillage.get(villageId)); + m.setVillageId(villageId); m.setIsActive(true); - m.setMoreData(toJson(v.getMoreData())); + m.setMoreData(toJson(request.getMoreData())); m.setCreatedBy(createdBy); toSave.add(m); } mappingRepo.saveAll(toSave); - logger.info("Nikshay device {} enrolled for {} village(s) by {}", device.getDeviceId(), toSave.size(), createdBy); + logger.info("Nikshay device {} enrolled for {} village(s) by {}", device.getDeviceSerialNumber(), toSave.size(), createdBy); Map result = new LinkedHashMap<>(); result.put("deviceDetailId", device.getId()); @@ -331,10 +426,11 @@ public List> getPhiByLocation(Integer stateId, Integer distr m.put("villageName", row[2]); m.put("facilityId", toInt(row[3])); m.put("tbuId", toInt(row[4])); + m.put("tbuName", row[11]); m.put("districtId", toInt(row[5])); m.put("stateId", toInt(row[6])); m.put("deviceDetailId", toInt(row[7])); - m.put("deviceId", row[8]); + m.put("deviceSerialNumber", row[8]); m.put("deviceName", row[9]); m.put("phiUserId", row[10]); result.add(m); @@ -342,6 +438,16 @@ public List> getPhiByLocation(Integer stateId, Integer distr return result; } + // Non-null ids in request order, without duplicates + private List distinctIds(List ids, String message) { + List result = ids == null ? new ArrayList<>() + : ids.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (result.isEmpty()) { + throw new IllegalArgumentException(message); + } + return result; + } + private NikshayDeviceConfiguration findDevice(Integer id) { return deviceRepo.findById(id) .orElseThrow(() -> new IllegalArgumentException("No device found with id " + id)); @@ -350,7 +456,7 @@ private NikshayDeviceConfiguration findDevice(Integer id) { private NikshayDeviceResponse toResponse(NikshayDeviceConfiguration d) { NikshayDeviceResponse r = new NikshayDeviceResponse(); r.setId(d.getId()); - r.setDeviceId(d.getDeviceId()); + r.setDeviceSerialNumber(d.getDeviceSerialNumber()); r.setDeviceName(d.getDeviceName()); r.setPhiUserId(d.getPhiUserId()); r.setIsActive(d.getIsActive()); From b8f3e2c330c3a1ebfd4250e4b2a4850350642a67 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Fri, 30 Oct 2026 17:33:14 +0530 Subject: [PATCH 4/4] stop:647 Nikshay Workflow complete integration. --- .gitignore | 6 +- nikshay-enroll/README.md | 40 + nikshay-enroll/lib/auth.js | 168 ++++ nikshay-enroll/lib/config.js | 17 + nikshay-enroll/login-json.js | 94 ++ nikshay-enroll/package-lock.json | 53 ++ nikshay-enroll/package.json | 15 + pom.xml | 2 +- src/main/environment/admin_ci.properties | 4 +- src/main/environment/admin_docker.properties | 56 +- src/main/environment/admin_example.properties | 2 + .../config/NikshayJobSchedulingConfig.java | 71 ++ .../NikshayJobLogController.java | 97 ++ .../NikshayPhaseController.java | 158 ++++ .../NikshayPhaseStatusController.java | 94 ++ .../NikshayEnrollWorkerController.java | 90 ++ .../admin/data/nikshay/DiagnosticOrder.java | 69 ++ .../admin/data/nikshay/DiagnosticResult.java | 63 ++ .../admin/data/nikshay/NikshayBenAddress.java | 54 ++ .../admin/data/nikshay/NikshayBenDetails.java | 50 + .../admin/data/nikshay/NikshayBenMapping.java | 56 ++ .../data/nikshay/NikshayBenRegIdMapping.java | 50 + .../data/nikshay/NikshayJobHistoryLog.java | 105 ++ .../NikshayEnrollResult.java | 54 ++ .../NikshayErroredBeneficiaryDTO.java | 44 + .../NikshayFlowScenario.java | 30 + .../NikshayHouseholdMemberDTO.java | 56 ++ .../NikshayIdGenerateBeneficiaryDTO.java | 82 ++ .../NikshayIdGenerateVillageBatch.java | 43 + .../nikshayidgenerate/NikshayLinkStatus.java | 34 + .../NikshayBeneficiaryStatusDTO.java | 45 + .../NikshayBeneficiaryStatusPage.java | 37 + .../NikshayBeneficiaryType.java | 32 + .../data/nikshayphasestatus/NikshayPhase.java | 52 + .../NikshayPhaseCandidate.java | 39 + .../NikshayPhaseDetailDTO.java | 41 + .../NikshayPhaseRunSettingDTO.java | 38 + .../NikshayPhaseStatus.java | 35 + .../NikshayPhaseStatusCount.java | 35 + .../nikshayphasestatus/NikshayRunMode.java | 30 + .../NikshayContactTestsDTO.java | 38 + .../NikshayDesignatedPhiDTO.java | 37 + .../data/nikshayupload/NaatResultDTO.java | 80 ++ .../NikshayDeviceCredentialDTO.java | 73 ++ .../data/nikshayupload/NikshayTestResult.java | 53 ++ .../nikshayupload/NikshayVillageBatch.java | 46 + .../data/nikshayupload/XrayResultDTO.java | 81 ++ .../NikshayIdGenerateRepository.java | 553 +++++++++++ .../NikshayJobHistoryLogRepo.java | 77 ++ .../NikshayPhaseRunSettingRepository.java | 98 ++ .../NikshayPhaseStatusRepository.java | 534 +++++++++++ .../NikshayTransferOutRepository.java | 153 +++ .../nikshayupload/DiagnosticOrderRepo.java | 108 +++ .../NikshayDeviceCredentialRepo.java | 55 ++ .../nikshayclient/NikshayApiException.java | 61 ++ .../nikshayclient/NikshayAuthClient.java | 199 ++++ .../NikshayDeviceCredentialService.java | 90 ++ .../NikshayDiagnosticsTestClient.java | 130 +++ .../nikshayclient/NikshayEnrollClient.java | 268 ++++++ .../nikshayclient/NikshayLoginException.java | 51 + .../service/nikshayclient/NikshaySession.java | 108 +++ .../nikshayclient/NikshayTransferClient.java | 146 +++ .../NikshayBeneficiaryValidator.java | 163 ++++ .../NikshayEnrollPayloadBuilder.java | 177 ++++ .../NikshayIdGenerateService.java | 895 ++++++++++++++++++ .../nikshayjoblog/NikshayFailureReason.java | 48 + .../NikshayJobHistoryLogService.java | 132 +++ .../nikshayjoblog/NikshayJobRunContext.java | 40 + .../nikshayjoblog/NikshayLogStatus.java | 27 + .../service/nikshayjoblog/NikshayPhase.java | 27 + .../nikshayjoblog/NikshayTriggerType.java | 27 + .../nikshayphasestatus/NikshayPhaseJob.java | 80 ++ .../nikshayphasestatus/NikshayPhaseRun.java | 46 + .../NikshayPhaseRunService.java | 131 +++ .../NikshayPhaseRunSettingService.java | 67 ++ .../NikshayPhaseStatusService.java | 323 +++++++ .../NikshayTransferOutService.java | 410 ++++++++ .../NikshayTransferPayloadBuilder.java | 62 ++ .../DiagnosticsTestPayloadBuilder.java | 151 +++ .../nikshayupload/NaatTestPayloadBuilder.java | 70 ++ .../NikshayTestPayloadBuilder.java | 44 + .../NikshayTestResultPusher.java | 132 +++ .../nikshayupload/NikshayTestType.java | 58 ++ .../nikshayupload/NikshayUploadService.java | 381 ++++++++ .../nikshayupload/RifTestPayloadBuilder.java | 78 ++ .../nikshayupload/XrayTestPayloadBuilder.java | 69 ++ 86 files changed, 8788 insertions(+), 30 deletions(-) create mode 100644 nikshay-enroll/README.md create mode 100644 nikshay-enroll/lib/auth.js create mode 100644 nikshay-enroll/lib/config.js create mode 100644 nikshay-enroll/login-json.js create mode 100644 nikshay-enroll/package-lock.json create mode 100644 nikshay-enroll/package.json create mode 100644 src/main/java/com/iemr/admin/config/NikshayJobSchedulingConfig.java create mode 100644 src/main/java/com/iemr/admin/controller/nikshayjoblog/NikshayJobLogController.java create mode 100644 src/main/java/com/iemr/admin/controller/nikshayphasestatus/NikshayPhaseController.java create mode 100644 src/main/java/com/iemr/admin/controller/nikshayphasestatus/NikshayPhaseStatusController.java create mode 100644 src/main/java/com/iemr/admin/controller/nikshayupload/NikshayEnrollWorkerController.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/DiagnosticOrder.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/DiagnosticResult.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayBenAddress.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayBenDetails.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayBenMapping.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayBenRegIdMapping.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayJobHistoryLog.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayEnrollResult.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayErroredBeneficiaryDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayFlowScenario.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayHouseholdMemberDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayIdGenerateBeneficiaryDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayIdGenerateVillageBatch.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayLinkStatus.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryStatusDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryStatusPage.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryType.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhase.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseCandidate.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseDetailDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseRunSettingDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseStatus.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseStatusCount.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayRunMode.java create mode 100644 src/main/java/com/iemr/admin/data/nikshaytransferout/NikshayContactTestsDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshaytransferout/NikshayDesignatedPhiDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayupload/NaatResultDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayupload/NikshayDeviceCredentialDTO.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayupload/NikshayTestResult.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayupload/NikshayVillageBatch.java create mode 100644 src/main/java/com/iemr/admin/data/nikshayupload/XrayResultDTO.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshayidgenerate/NikshayIdGenerateRepository.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshayjoblog/NikshayJobHistoryLogRepo.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshayphasestatus/NikshayPhaseRunSettingRepository.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshayphasestatus/NikshayPhaseStatusRepository.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshaytransferout/NikshayTransferOutRepository.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshayupload/DiagnosticOrderRepo.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshayupload/NikshayDeviceCredentialRepo.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayclient/NikshayApiException.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayclient/NikshayAuthClient.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayclient/NikshayDeviceCredentialService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayclient/NikshayDiagnosticsTestClient.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayclient/NikshayEnrollClient.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayclient/NikshayLoginException.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayclient/NikshaySession.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayclient/NikshayTransferClient.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayBeneficiaryValidator.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayEnrollPayloadBuilder.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayIdGenerateService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayFailureReason.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobHistoryLogService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobRunContext.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayLogStatus.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayPhase.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayTriggerType.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseJob.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRun.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunSettingService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseStatusService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferOutService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferPayloadBuilder.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayupload/DiagnosticsTestPayloadBuilder.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayupload/NaatTestPayloadBuilder.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestPayloadBuilder.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestResultPusher.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestType.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayupload/NikshayUploadService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayupload/RifTestPayloadBuilder.java create mode 100644 src/main/java/com/iemr/admin/service/nikshayupload/XrayTestPayloadBuilder.java diff --git a/.gitignore b/.gitignore index c9a2255a..5db74692 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,8 @@ src/main/environment/admin_local.properties node_modules # Logs -logs/ \ No newline at end of file +logs/ +*.api-results.csv +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/nikshay-enroll/README.md b/nikshay-enroll/README.md new file mode 100644 index 00000000..8158fb28 --- /dev/null +++ b/nikshay-enroll/README.md @@ -0,0 +1,40 @@ +# Ni-kshay login worker (Admin-API) + +Node + Playwright worker that logs in to the Ni-kshay portal in a real browser and +prints the portal's session cookies as JSON. Admin-API (`NikshayAuthClient`) runs it +before every Ni-kshay phase run (ENROLMENT, CONTACTS, NAAT_UPLOAD, XRAY_UPLOAD) and for +`POST /stopTb/nikshay/testLogin`. All other Ni-kshay calls are made by Admin-API itself. + +Files: + +- `login-json.js`: the worker Admin-API runs +- `lib/auth.js`: the login steps on the portal page +- `lib/config.js`: the portal URLs for `uat` (beta.nikshay.in) and `production` + +## Setup (on the machine that runs Admin-API) + +```bash +cd nikshay-enroll +npm install +``` + +Requires Node.js and Chromium (installed via the Playwright postinstall). + +Config in the Admin-API properties (defaults shown): + +``` +nikshay.enroll.dir=nikshay-enroll +nikshay.enroll.node=node +nikshay.login.timeout-ms=180000 +nikshay.portal.env=uat +``` + +`nikshay.enroll.dir` is resolved from the Admin-API working directory. + +## Run by hand + +```bash +NIKSHAY_USER=... NIKSHAY_PASS=... node login-json.js --env uat +``` + +The output is a live login: never log it or share it. diff --git a/nikshay-enroll/lib/auth.js b/nikshay-enroll/lib/auth.js new file mode 100644 index 00000000..408402a3 --- /dev/null +++ b/nikshay-enroll/lib/auth.js @@ -0,0 +1,168 @@ +/** + * Ni-kshay SSO login via Playwright (headless by default). + */ + +function randomCaptcha() { + return Math.random().toString(36).slice(2, 8).toUpperCase() +} + +async function resolveCaptcha(page) { + const codeEl = page.locator('#capText') + if ((await codeEl.count()) === 0) return randomCaptcha() + for (let i = 0; i < 25; i++) { + const code = ((await codeEl.textContent().catch(() => '')) || '').trim() + if (code.length >= 4) return code + await page.waitForTimeout(300) + } + return randomCaptcha() +} + +async function pageCanHashPassword(page, timeoutMs) { + if ((await page.locator('input[name="isEncryptedData"][value="true"]').count()) === 0) return true + return page + .waitForFunction( + () => { + const $ = window.jQuery + if (typeof $ !== 'function' || !window.forge || typeof window.md5 !== 'function') return false + const button = $('.loginButton')[0] + return Boolean(button && $._data?.(button, 'events')?.click?.length) + }, + undefined, + { timeout: timeoutMs } + ) + .then(() => true) + .catch(() => false) +} + +async function submitWithOwnHashing(page, username, password) { + const hash = await page.evaluate(async (args) => { + const clientId = document.querySelector('input[name="clientId"]')?.value + if (!clientId) return 'ERR:the login page is missing its clientId field' + const base = location.origin + const idHeader = { 'X-SSO-Client-Id': clientId } + + const tokenRes = await ( + await fetch(`${base}/v1/ssoserver/client`, { credentials: 'include', headers: idHeader }) + ).json() + const authToken = tokenRes?.data?.authToken + if (!authToken) return 'ERR:the portal did not issue an auth token' + + const salt = await ( + await fetch(`${base}/v1/user/saltKey?username=${encodeURIComponent(args.user)}`, { + credentials: 'include', + headers: { ...idHeader, 'X-SSO-Auth-Token': authToken } + }) + ).json() + if (!salt?.success || !salt.data) return 'ERR:the portal did not return a salt for this username' + + const tokenRes2 = await ( + await fetch(`${base}/v1/ssoserver/client`, { credentials: 'include', headers: idHeader }) + ).json() + const sessionRes = await ( + await fetch(`${base}/v1/ssoserver/session-key`, { + credentials: 'include', + headers: { ...idHeader, 'X-SSO-Auth-Token': tokenRes2?.data?.authToken ?? '' } + }) + ).json() + if (!sessionRes?.success || !sessionRes.data) return 'ERR:the portal did not return a session key' + + let digest = '' + for (let round = 0; round < 2; round++) { + const input = round === 0 ? salt.data + args.pass : sessionRes.data + digest + const bytes = new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)) + ) + digest = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + } + return digest + }, { user: username, pass: password }) + + if (hash.startsWith('ERR:')) return hash.slice(4) + + await page + .evaluate((hashed) => { + const pw = document.querySelector('#password') + const external = document.querySelector('#isExternalLogin') + if (external) external.value = 'false' + if (!pw) return + pw.value = hashed + pw.form?.submit() + }, hash) + .catch(() => undefined) + return null +} + +/** + * When the user already has a portal session elsewhere (another browser, or another run), + * the portal shows "You are already logged in. Click here to go to Overview Page" instead + * of the dashboard. Follows that link. Returns true when the page was that one and the + * portal opened afterwards. + */ +async function continueIfAlreadyLoggedIn(page) { + const text = (await page.locator('body').innerText({ timeout: 3_000 }).catch(() => '')) || '' + if (!/already logged in/i.test(text)) return false + const link = page.getByText(/click here/i).first() + if ((await link.count()) === 0) return false + await link.click() + await page.waitForLoadState('domcontentloaded').catch(() => undefined) + await page.waitForTimeout(2_000) + const url = new URL(page.url()) + // Back on the SSO login page: the click did not open the portal + return !url.hostname.startsWith('sso.') && !/already logged in/i.test( + (await page.locator('body').innerText({ timeout: 3_000 }).catch(() => '')) || '') +} + +/** Log in once. Throws on failure. */ +export async function login(page, username, password, loginUrl, homeUrlGlob) { + const alerts = [] + const onDialog = (d) => { + alerts.push(d.message()) + void d.dismiss().catch(() => undefined) + } + page.on('dialog', onDialog) + + try { + await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }) + const pageHashes = await pageCanHashPassword(page, 8_000) + + await page.getByRole('textbox', { name: 'User Name' }).fill(username) + await page.getByRole('textbox', { name: 'Password' }).fill(password) + + const captcha = page.getByRole('textbox', { name: 'captcha' }) + if ((await captcha.count()) > 0) { + await captcha.first().fill(await resolveCaptcha(page)) + } + + if (pageHashes) { + await page.getByRole('button', { name: 'Login' }).click() + } else { + const failedStep = await submitWithOwnHashing(page, username, password) + if (failedStep) throw new Error(`Login failed: ${failedStep}.`) + } + + try { + await page.waitForURL(homeUrlGlob, { timeout: 20_000 }) + } catch { + if (await continueIfAlreadyLoggedIn(page)) return + const msg = + (await page.locator('#setError').first().textContent().catch(() => null)) || + (await page + .locator('text=/invalid|incorrect|wrong|failed|error/i') + .first() + .textContent() + .catch(() => null)) + const detail = [msg?.trim(), alerts.length ? `page alert: ${alerts.join('; ')}` : ''] + .filter(Boolean) + .join(' — ') + throw new Error( + detail + ? `Login failed: ${detail}` + : 'Login failed: did not reach the dashboard. Check username/password.' + ) + } + } finally { + page.off('dialog', onDialog) + } +} diff --git a/nikshay-enroll/lib/config.js b/nikshay-enroll/lib/config.js new file mode 100644 index 00000000..89c2d0f2 --- /dev/null +++ b/nikshay-enroll/lib/config.js @@ -0,0 +1,17 @@ +/** UAT (beta) is the default. Pass `production` as last CLI arg to switch. */ +export const ENV = { + uat: { + loginUrl: + 'https://sso.beta.nikshay.in/v1/sso/login?returnUrl=https://beta.nikshay.in/Home&clientId=29', + homeUrlGlob: '**/beta.nikshay.in/Home', + origin: 'https://beta.nikshay.in' + }, + production: { + loginUrl: + 'https://sso.nikshay.in/v1/sso/login?returnUrl=https://www.nikshay.in/Home&clientId=29', + homeUrlGlob: '**/www.nikshay.in/Home', + origin: 'https://www.nikshay.in' + } +} + +export const DEFAULT_ENV = 'uat' diff --git a/nikshay-enroll/login-json.js b/nikshay-enroll/login-json.js new file mode 100644 index 00000000..793467bf --- /dev/null +++ b/nikshay-enroll/login-json.js @@ -0,0 +1,94 @@ +/** + * CLI for Admin-API (NikshayAuthClient): log in to Ni-kshay in a real browser and + * print the portal's session cookies as ONE JSON object on stdout, so Java can + * make the portal API calls itself. + * + * NIKSHAY_USER=… NIKSHAY_PASS=… node login-json.js --env uat|production + * + * stdout on success: { "origin": "https://beta.nikshay.in", "cookies": [ { "name", "value" }, … ] } + * stdout on failure: { "error": "…", "rejected": true|false } (exit code 1) + * rejected = the portal refused the login (wrong username/password/captcha); + * otherwise the portal couldn't be reached or answered unexpectedly. + * + * The cookies are a live login: stdout is read only by Admin-API and must never be + * logged. Progress goes to stderr and never includes the password or cookies. + */ +import { chromium } from 'playwright' +import { ENV, DEFAULT_ENV } from './lib/config.js' +import { login } from './lib/auth.js' + +function arg(name) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : undefined +} + +function fail(message, rejected) { + console.error(`login failed: ${message}`) + process.stdout.write(JSON.stringify({ error: message, rejected })) + process.exitCode = 1 +} + +const envName = arg('env') || process.env.NIKSHAY_ENV || DEFAULT_ENV +const urls = ENV[envName] +const username = process.env.NIKSHAY_USER +const password = process.env.NIKSHAY_PASS + +if (!urls) { + fail(`unknown env "${envName}" (use uat or production)`, false) +} else if (!username || !password) { + fail('NIKSHAY_USER and NIKSHAY_PASS are required', true) +} else { + const headless = process.env.HEADLESS !== '0' + // Prefer system Google Chrome when present; PLAYWRIGHT_CHROME=0 uses Playwright's Chromium. + const useSystemChrome = process.env.PLAYWRIGHT_CHROME !== '0' + let browser + let page + try { + console.error(`login: ${envName} (${urls.origin}) user=${username}`) + browser = await chromium.launch({ headless, ...(useSystemChrome ? { channel: 'chrome' } : {}) }) + const context = await browser.newContext() + page = await context.newPage() + page.setDefaultTimeout(30_000) + // The login page waits for jQuery/forge/md5 from public CDNs; loading it has taken + // 2-10s and sometimes more than 30s, so page loads get a longer limit. + page.setDefaultNavigationTimeout(90_000) + await login(page, username, password, urls.loginUrl, urls.homeUrlGlob) + if (!page.url().startsWith(urls.origin)) { + await page.goto(`${urls.origin}/Home`, { waitUntil: 'domcontentloaded' }) + } + const cookies = (await context.cookies(urls.origin)).map((c) => ({ name: c.name, value: c.value })) + console.error(`login: ok (${cookies.length} cookies)`) + process.stdout.write(JSON.stringify({ origin: urls.origin, cookies })) + } catch (e) { + let message = (e && e.message ? e.message : String(e)).split('\n')[0] + // Right credentials, but Ni-kshay forces a password change first (first login or + // expired password). Never changed automatically: someone has to do it by hand. + if (page && new URL(page.url()).pathname.endsWith('/resetPassword')) { + message = 'Login failed: Ni-kshay requires a password change for this login. Log in to the portal by hand, ' + + 'set a new password, then update it in the device configuration.' + } + // lib/auth.js throws "Login failed: …" when the portal refuses the login + fail(message + (page ? await whereItStopped(page) : ''), message.startsWith('Login failed')) + } finally { + if (browser) await browser.close().catch(() => undefined) + } +} + +/** + * Where the browser ended up after a failed login: URL without the query, page + * title and the start of the visible text (e.g. a 403 or error page). Visible + * text never includes what was typed into the password field. + */ +async function whereItStopped(page) { + try { + const url = new URL(page.url()) + const title = (await page.title().catch(() => '')).trim() + const text = ((await page.locator('body').innerText({ timeout: 3_000 }).catch(() => '')) || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 300) + return ` [ended at ${url.origin}${url.pathname}; title "${title}"; page text: "${text}"]` + } catch { + return '' + } +} diff --git a/nikshay-enroll/package-lock.json b/nikshay-enroll/package-lock.json new file mode 100644 index 00000000..077437c8 --- /dev/null +++ b/nikshay-enroll/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "mmu-api-nikshay-enroll", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mmu-api-nikshay-enroll", + "version": "0.1.0", + "hasInstallScript": true, + "dependencies": { + "csv-parse": "^5.5.6", + "csv-stringify": "^6.5.1", + "playwright": "^1.48.0" + } + }, + "node_modules/csv-parse": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==" + }, + "node_modules/csv-stringify": { + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz", + "integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==" + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/nikshay-enroll/package.json b/nikshay-enroll/package.json new file mode 100644 index 00000000..29f22d74 --- /dev/null +++ b/nikshay-enroll/package.json @@ -0,0 +1,15 @@ +{ + "name": "mmu-api-nikshay-enroll", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Playwright enroll worker invoked by MMU-API generateIds", + "scripts": { + "postinstall": "npx playwright install chromium" + }, + "dependencies": { + "csv-parse": "^5.5.6", + "csv-stringify": "^6.5.1", + "playwright": "^1.48.0" + } +} diff --git a/pom.xml b/pom.xml index 062df585..15a10cfa 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.iemr.admin admin-api - 3.8.2 + 3.13.0 war Admin-API Admin Page diff --git a/src/main/environment/admin_ci.properties b/src/main/environment/admin_ci.properties index 75042e11..16a09f6c 100644 --- a/src/main/environment/admin_ci.properties +++ b/src/main/environment/admin_ci.properties @@ -28,4 +28,6 @@ springdoc.swagger-ui.enabled=@env.SWAGGER_DOC_ENABLED@ cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ # Ni-kshay device PHI password encryption key (Base64, 32 bytes); MMU-API must use the same key -nikshay.device.aes-key=@env.NIKSHAY_DEVICE_AES_KEY@ \ No newline at end of file +nikshay.device.aes-key=@env.NIKSHAY_DEVICE_AES_KEY@ +nikshay.portal.env=@env.NIKSHAY_PORTAL_ENV@ +nikshay.job.cron=@env.NIKSHAY_JOB_CRON@ \ No newline at end of file diff --git a/src/main/environment/admin_docker.properties b/src/main/environment/admin_docker.properties index e21387c3..47357172 100644 --- a/src/main/environment/admin_docker.properties +++ b/src/main/environment/admin_docker.properties @@ -1,27 +1,29 @@ -# local env -# DB Connections -spring.datasource.url=${DATABASE_URL} -spring.datasource.username=${DATABASE_USERNAME} -spring.datasource.password=${DATABASE_PASSWORD} -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver - -callcentre-server-ip=${CALLCENTRE_SERVER_IP} - -videoConsultation-apikey=${SWYMED_APIKEY} -videoConsultation-base-url=${SWYMED_BASE_URL} - -### Redis IP -spring.redis.host=${REDIS_HOST} -spring.main.allow-bean-definition-overriding=true -jwt.secret=${JWT_SECRET_KEY} -#ELK logging file name -logging.path=logs/ -logging.file.name=${ADMIN_API_LOGGING_FILE_NAME} - -common-url=${COMMON_API} - -springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED} -springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED} - -# Ni-kshay device PHI password encryption key (Base64, 32 bytes); MMU-API must use the same key -nikshay.device.aes-key=${NIKSHAY_DEVICE_AES_KEY:} \ No newline at end of file +# local env +# DB Connections +spring.datasource.url=${DATABASE_URL} +spring.datasource.username=${DATABASE_USERNAME} +spring.datasource.password=${DATABASE_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +callcentre-server-ip=${CALLCENTRE_SERVER_IP} + +videoConsultation-apikey=${SWYMED_APIKEY} +videoConsultation-base-url=${SWYMED_BASE_URL} + +### Redis IP +spring.redis.host=${REDIS_HOST} +spring.main.allow-bean-definition-overriding=true +jwt.secret=${JWT_SECRET_KEY} +#ELK logging file name +logging.path=logs/ +logging.file.name=${ADMIN_API_LOGGING_FILE_NAME} + +common-url=${COMMON_API} + +springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED} +springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED} + +# Ni-kshay device PHI password encryption key (Base64, 32 bytes); MMU-API must use the same key +nikshay.device.aes-key=${NIKSHAY_DEVICE_AES_KEY:} +nikshay.portal.env=${NIKSHAY_PORTAL_ENV:uat} +nikshay.job.cron=${NIKSHAY_JOB_CRON:} \ No newline at end of file diff --git a/src/main/environment/admin_example.properties b/src/main/environment/admin_example.properties index ecba7093..fea8acd0 100644 --- a/src/main/environment/admin_example.properties +++ b/src/main/environment/admin_example.properties @@ -28,3 +28,5 @@ jwt.secret=my-32-character-ultra-secure-and-ultra-long-secret cors.allowed-origins=http://localhost:* nikshay.device.aes-key=${NIKSHAY_DEVICE_AES_KEY:} +nikshay.portal.env=uat +nikshay.job.cron= diff --git a/src/main/java/com/iemr/admin/config/NikshayJobSchedulingConfig.java b/src/main/java/com/iemr/admin/config/NikshayJobSchedulingConfig.java new file mode 100644 index 00000000..82d4319a --- /dev/null +++ b/src/main/java/com/iemr/admin/config/NikshayJobSchedulingConfig.java @@ -0,0 +1,71 @@ +/* +* 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.admin.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.scheduling.support.CronExpression; + +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseJob; + +/** + * Schedules the automatic Ni-kshay job (NikshayPhaseJob) with the cron expression in + * nikshay.job.cron (Spring format: second minute hour day month weekday). The job is not + * scheduled when the property is missing, empty or not a valid cron expression. Runs on + * one thread, so a run never overlaps the previous one. + */ +@Configuration +@EnableScheduling +public class NikshayJobSchedulingConfig implements SchedulingConfigurer { + private static final Logger logger = LoggerFactory.getLogger(NikshayJobSchedulingConfig.class); + + static final String CRON_PROPERTY = "nikshay.job.cron"; + + @Autowired + private Environment environment; + + @Autowired + private NikshayPhaseJob nikshayPhaseJob; + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + String cron = environment.getProperty(CRON_PROPERTY); + if (cron == null || cron.isBlank()) { + logger.info("{} is not set: the automatic Ni-kshay job does not run", CRON_PROPERTY); + return; + } + cron = cron.trim(); + if (!CronExpression.isValidExpression(cron)) { + logger.error("{} '{}' is not a valid cron expression: the automatic Ni-kshay job does not run", + CRON_PROPERTY, cron); + return; + } + taskRegistrar.addCronTask(nikshayPhaseJob::runAutomaticPhases, cron); + logger.info("Automatic Ni-kshay job scheduled with {} '{}'", CRON_PROPERTY, cron); + } +} diff --git a/src/main/java/com/iemr/admin/controller/nikshayjoblog/NikshayJobLogController.java b/src/main/java/com/iemr/admin/controller/nikshayjoblog/NikshayJobLogController.java new file mode 100644 index 00000000..2370d727 --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/nikshayjoblog/NikshayJobLogController.java @@ -0,0 +1,97 @@ +/* +* 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.admin.controller.nikshayjoblog; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; +import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; +import com.iemr.admin.service.nikshayjoblog.NikshayPhase; + +import io.swagger.v3.oas.annotations.Operation; + +/** Read side of the Ni-kshay phase history: failed items and one beneficiary's history. */ +@RestController +@RequestMapping(value = "/nikshay/log", headers = "Authorization") +public class NikshayJobLogController { + private static final Logger logger = LoggerFactory.getLogger(NikshayJobLogController.class); + + private static final int MAX_PAGE_SIZE = 200; + + @Autowired + private NikshayJobHistoryLogService logService; + + @Operation(summary = "Beneficiaries whose latest attempt of a phase failed, with reason, attempts and last attempt time") + @GetMapping(value = "/failed") + public ResponseEntity getFailed(@RequestParam(value = "phase", required = false) NikshayPhase phase, + @RequestParam(value = "reason", required = false) NikshayFailureReason reason, + @RequestParam(value = "benId", required = false) Long benId, + @RequestParam(value = "fromDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(value = "toDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "50") int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + return ResponseEntity.badRequest().body("page must be >= 0 and size between 1 and " + MAX_PAGE_SIZE); + } + try { + // Dates are whole days: toDate is included, so the query bound is the next day's start. + Page failed = logService.getFailed(phase, reason, benId, + fromDate != null ? Timestamp.valueOf(fromDate.atStartOfDay()) : null, + toDate != null ? Timestamp.valueOf(toDate.plusDays(1).atStartOfDay()) : null, page, size); + Map body = new HashMap<>(); + body.put("entries", failed.getContent()); + body.put("totalElements", failed.getTotalElements()); + body.put("page", page); + body.put("size", size); + return ResponseEntity.ok(body); + } catch (Exception e) { + logger.error("Error reading failed Ni-kshay beneficiaries", e); + return ResponseEntity.status(500).body("Could not read failed beneficiaries"); + } + } + + @Operation(summary = "Every Ni-kshay phase entry of one beneficiary (benRegId), newest first") + @GetMapping(value = "/history") + public ResponseEntity getHistory(@RequestParam("benId") Long benId) { + try { + return ResponseEntity.ok(logService.getHistory(benId)); + } catch (Exception e) { + logger.error("Error reading Ni-kshay history for ben {}", benId, e); + return ResponseEntity.status(500).body("Could not read the beneficiary's history"); + } + } +} diff --git a/src/main/java/com/iemr/admin/controller/nikshayphasestatus/NikshayPhaseController.java b/src/main/java/com/iemr/admin/controller/nikshayphasestatus/NikshayPhaseController.java new file mode 100644 index 00000000..f08b7524 --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/nikshayphasestatus/NikshayPhaseController.java @@ -0,0 +1,158 @@ +/* +* 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.admin.controller.nikshayphasestatus; + +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayRunMode; +import com.iemr.admin.service.nikshayjoblog.NikshayJobRunContext; +import com.iemr.admin.service.nikshayjoblog.NikshayTriggerType; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseRunService; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseRunSettingService; +import com.iemr.admin.utils.JwtUtil; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Runs the Ni-kshay phases one at a time (ENROLMENT, XRAY_UPLOAD, NAAT_UPLOAD, CONTACTS) and + * sets their run mode (automatic job or manual push only). + */ +@RestController +@RequestMapping(value = "/nikshay/phase", headers = "Authorization") +public class NikshayPhaseController { + private static final Logger logger = LoggerFactory.getLogger(NikshayPhaseController.class); + + @Autowired + private NikshayPhaseRunService phaseRunService; + + @Autowired + private NikshayPhaseRunSettingService settingService; + + @Autowired + private JwtUtil jwtUtil; + + @Operation(summary = "Run a Ni-kshay phase for the beneficiaries waiting in it, or for the given BenRegIds " + + "(optional body); returns the ones that failed") + @PostMapping(value = "/{phase}/run") + public ResponseEntity run(@PathVariable("phase") NikshayPhase phase, + @RequestBody(required = false) List benRegIds, HttpServletRequest request) { + try { + List errored = phaseRunService.run(phase, + benRegIds == null || benRegIds.isEmpty() ? null : benRegIds, context(request)); + return ResponseEntity.ok(errored); + } catch (IllegalStateException e) { + return ResponseEntity.status(409).body(e.getMessage()); + } catch (Exception e) { + logger.error("Error running Ni-kshay phase {}", phase, e); + return ResponseEntity.status(500).body("Could not run Ni-kshay phase " + phase); + } + } + + @Operation(summary = "Set the given BenRegIds back to PENDING in a Ni-kshay phase (reprocess); with runNow=true, " + + "run the phase for them straight away and return the ones that failed") + @PostMapping(value = "/{phase}/reprocess") + public ResponseEntity reprocess(@PathVariable("phase") NikshayPhase phase, @RequestBody List benRegIds, + @RequestParam(value = "runNow", defaultValue = "false") boolean runNow, HttpServletRequest request) { + if (benRegIds == null || benRegIds.isEmpty()) { + return ResponseEntity.badRequest().body("At least one BenRegId is required"); + } + try { + List errored = phaseRunService.reprocess(phase, benRegIds, runNow, + context(request)); + return ResponseEntity.ok(errored != null ? errored + : benRegIds.size() + " beneficiaries set for reprocess in " + phase); + } catch (IllegalStateException e) { + return ResponseEntity.status(409).body(e.getMessage()); + } catch (Exception e) { + logger.error("Error reprocessing Ni-kshay phase {}", phase, e); + return ResponseEntity.status(500).body("Could not reprocess Ni-kshay phase " + phase); + } + } + + @Operation(summary = "Run mode of each Ni-kshay phase: AUTOMATIC (run by the scheduled job and by manual push) " + + "or MANUAL (manual push only)") + @GetMapping(value = "/settings") + public ResponseEntity settings() { + try { + return ResponseEntity.ok(settingService.findAll()); + } catch (Exception e) { + logger.error("Error reading the Ni-kshay phase settings", e); + return ResponseEntity.status(500).body("Could not read the Ni-kshay phase settings"); + } + } + + @Operation(summary = "Set a Ni-kshay phase's run mode, body {\"runMode\": \"AUTOMATIC\"} or {\"runMode\": \"MANUAL\"}; " + + "returns the saved setting") + @PutMapping(value = "/{phase}/setting") + public ResponseEntity setRunMode(@PathVariable("phase") NikshayPhase phase, + @RequestBody Map body, HttpServletRequest request) { + String value = body == null ? null : body.get("runMode"); + NikshayRunMode runMode; + try { + runMode = NikshayRunMode.valueOf(value == null ? "" : value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body("runMode must be AUTOMATIC or MANUAL"); + } + try { + return ResponseEntity.ok(settingService.setRunMode(phase, runMode, currentUsername(request))); + } catch (Exception e) { + logger.error("Error setting the run mode of Ni-kshay phase {}", phase, e); + return ResponseEntity.status(500).body("Could not set the run mode of Ni-kshay phase " + phase); + } + } + + private NikshayJobRunContext context(HttpServletRequest request) { + return new NikshayJobRunContext(NikshayTriggerType.MANUAL, currentUsername(request)); + } + + /** Best-effort: only recorded as triggered_by in the history log. */ + private String currentUsername(HttpServletRequest request) { + try { + String header = request.getHeader("Authorization"); + if (header == null) { + return "unknown"; + } + String token = header.startsWith("Bearer ") ? header.substring(7) : header; + String username = jwtUtil.extractUsername(token); + return username != null ? username : "unknown"; + } catch (Exception e) { + return "unknown"; + } + } +} diff --git a/src/main/java/com/iemr/admin/controller/nikshayphasestatus/NikshayPhaseStatusController.java b/src/main/java/com/iemr/admin/controller/nikshayphasestatus/NikshayPhaseStatusController.java new file mode 100644 index 00000000..e8902dc2 --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/nikshayphasestatus/NikshayPhaseStatusController.java @@ -0,0 +1,94 @@ +/* +* 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.admin.controller.nikshayphasestatus; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.admin.data.nikshayphasestatus.NikshayBeneficiaryType; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseStatus; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseStatusService; + +import io.swagger.v3.oas.annotations.Operation; + +/** Ni-kshay status of each beneficiary in each phase, for the Failed Beneficiaries tab and the sync status. */ +@RestController +@RequestMapping(value = "/nikshay/beneficiaryStatus", headers = "Authorization") +public class NikshayPhaseStatusController { + private static final Logger logger = LoggerFactory.getLogger(NikshayPhaseStatusController.class); + + private static final int MAX_PAGE_SIZE = 200; + + @Autowired + private NikshayPhaseStatusService phaseStatusService; + + @Operation(summary = "Beneficiaries with their status in every Ni-kshay phase. Filters (all optional): phase, " + + "status (PENDING, IN_PROGRESS, DONE, FAILED, NOT_APPLICABLE or NOT_STARTED), failedOnly (with the " + + "failed phase, reason and error), failureReason, beneficiaryType (INDEX, CONTACT, ALL_NEGATIVE), " + + "search (BenRegId or name); page from 0") + @GetMapping(value = "/list") + public ResponseEntity list(@RequestParam(required = false) NikshayPhase phase, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "false") boolean failedOnly, + @RequestParam(required = false) NikshayFailureReason failureReason, + @RequestParam(required = false) NikshayBeneficiaryType beneficiaryType, + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + if (status != null && !status.equals("NOT_STARTED")) { + try { + NikshayPhaseStatus.valueOf(status); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body("Unknown status " + status); + } + } + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + return ResponseEntity.badRequest().body("page must be 0 or more and size 1 to " + MAX_PAGE_SIZE); + } + try { + return ResponseEntity.ok(phaseStatusService.getStatusList(phase, status, failedOnly, failureReason, + beneficiaryType, search, page, size)); + } catch (Exception e) { + logger.error("Error reading the Ni-kshay beneficiary status list", e); + return ResponseEntity.status(500).body("Could not read the Ni-kshay beneficiary status list"); + } + } + + @Operation(summary = "Number of beneficiaries per Ni-kshay phase and status") + @GetMapping(value = "/summary") + public ResponseEntity summary() { + try { + return ResponseEntity.ok(phaseStatusService.getSummary()); + } catch (Exception e) { + logger.error("Error reading the Ni-kshay phase status summary", e); + return ResponseEntity.status(500).body("Could not read the Ni-kshay phase status summary"); + } + } +} diff --git a/src/main/java/com/iemr/admin/controller/nikshayupload/NikshayEnrollWorkerController.java b/src/main/java/com/iemr/admin/controller/nikshayupload/NikshayEnrollWorkerController.java new file mode 100644 index 00000000..a12a068e --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/nikshayupload/NikshayEnrollWorkerController.java @@ -0,0 +1,90 @@ +/* +* 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.admin.controller.nikshayupload; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.admin.service.nikshayclient.NikshayAuthClient; +import com.iemr.admin.service.nikshayclient.NikshayLoginException; +import com.iemr.admin.service.nikshayclient.NikshaySession; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +import io.swagger.v3.oas.annotations.Operation; + +/** + * Test endpoint for the Ni-kshay login, which runs the nikshay-enroll Node worker + * (login-json.js) through NikshayAuthClient. + */ +@RestController +@RequestMapping(value = "/stopTb/nikshay", headers = "Authorization") +public class NikshayEnrollWorkerController { + private static final Logger logger = LoggerFactory.getLogger(NikshayEnrollWorkerController.class); + + @Autowired + private NikshayAuthClient nikshayAuthClient; + + @Value("${nikshay.portal.env:uat}") + private String nikshayPortalEnv; + + /** Test only: checks a Ni-kshay login. Returns no cookies or tokens, only the outcome. */ + @Operation(summary = "Test a Ni-kshay portal login (username + password); returns success or the failure reason") + @PostMapping(value = "/testLogin", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity testLogin(@RequestBody Map body) { + String username = body != null ? body.get("username") : null; + String password = body != null ? body.get("password") : null; + if (username == null || username.isBlank() || password == null || password.isEmpty()) { + return ResponseEntity.badRequest().body("username and password are required"); + } + Map result = new LinkedHashMap<>(); + result.put("username", username); + result.put("portal", nikshayPortalEnv); + try { + NikshaySession session = nikshayAuthClient.login(username, password); + result.put("success", true); + result.put("portalUrl", session.getPortalUrl()); + result.put("loggedInAt", session.getLoggedInAt().toString()); + return ResponseEntity.ok(result); + } catch (NikshayLoginException e) { + result.put("success", false); + result.put("reason", e.getReason()); + result.put("message", e.getMessage()); + // Wrong credentials: 401; portal down or unexpected answer: 503 + return ResponseEntity + .status(e.getReason() == NikshayFailureReason.LOGIN_REJECTED ? 401 : 503).body(result); + } catch (Exception e) { + logger.error("Error testing Ni-kshay login for {}", username, e); + return ResponseEntity.status(500).body("Could not test the Ni-kshay login"); + } + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/DiagnosticOrder.java b/src/main/java/com/iemr/admin/data/nikshay/DiagnosticOrder.java new file mode 100644 index 00000000..16cfe0ff --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/DiagnosticOrder.java @@ -0,0 +1,69 @@ +/* +* 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.admin.data.nikshay; + +import java.sql.Timestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Data; + +/** + * Diagnostic device order (X-ray / TrueNat), written by FLW-API on the van and + * synced here. Mapped read-only, with only the columns the Ni-kshay upload needs. + */ +@Data +@Entity +@Table(name = "tb_diagnostic_order") +public class DiagnosticOrder { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "beneficiary_id") + private Long beneficiaryId; + + @Column(name = "visitCode") + private Long visitCode; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "order_type") + private String orderType; + + @Column(name = "external_order_id") + private String externalOrderId; + + @Column(name = "status") + private String status; + + @Column(name = "created_date") + private Timestamp createdDate; + + @Column(name = "deleted") + private Boolean deleted; +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/DiagnosticResult.java b/src/main/java/com/iemr/admin/data/nikshay/DiagnosticResult.java new file mode 100644 index 00000000..7cd5fbf9 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/DiagnosticResult.java @@ -0,0 +1,63 @@ +/* +* 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.admin.data.nikshay; + +import java.sql.Timestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Data; + +/** + * Result of a diagnostic device order, written by FLW-API on the van and + * synced here. Linked to its order by external_order_id, not + * diagnostic_order_id — the latter is a per-van AUTO_INCREMENT that collides + * once orders from different vans meet centrally (see AMRIT-DB V103). + * Mapped read-only, with only the columns the Ni-kshay upload needs. + */ +@Data +@Entity +@Table(name = "tb_diagnostic_result") +public class DiagnosticResult { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "external_order_id") + private String externalOrderId; + + @Column(name = "result_summary") + private String resultSummary; + + @Column(name = "tb_presence") + private Boolean tbPresence; + + @Column(name = "created_date") + private Timestamp createdDate; + + @Column(name = "deleted") + private Boolean deleted; +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayBenAddress.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayBenAddress.java new file mode 100644 index 00000000..e24da103 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayBenAddress.java @@ -0,0 +1,54 @@ +/* +* 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.admin.data.nikshay; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Data; + +/** + * Beneficiary address, holding the current village and state (db_identity.i_beneficiaryaddress). + * Mapped read-only, with only the columns the Ni-kshay upload needs. db_identity + * is a separate database on the same MySQL server, so it's given as the catalog. + */ +@Data +@Entity +@Table(name = "i_beneficiaryaddress", catalog = "db_identity") +public class NikshayBenAddress { + + @Id + @Column(name = "BenAddressID") + private Long benAddressID; + + @Column(name = "CurrVillageId") + private Integer currVillageId; + + @Column(name = "CurrVillage") + private String currVillage; + + // AMRIT state ID (m_state), not a Ni-kshay one + @Column(name = "CurrStateId") + private Integer currStateId; +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayBenDetails.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayBenDetails.java new file mode 100644 index 00000000..fa6c40c2 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayBenDetails.java @@ -0,0 +1,50 @@ +/* +* 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.admin.data.nikshay; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Data; + +/** + * Beneficiary details, holding the Ni-kshay ID (db_identity.i_beneficiarydetails). + * Mapped read-only, with only the columns the Ni-kshay upload needs. db_identity + * is a separate database on the same MySQL server, so it's given as the catalog. + */ +@Data +@Entity +@Table(name = "i_beneficiarydetails", catalog = "db_identity") +public class NikshayBenDetails { + + @Id + @Column(name = "BeneficiaryDetailsId") + private Long beneficiaryDetailsId; + + @Column(name = "nikshayId") + private String nikshayId; + + @Column(name = "Deleted") + private Boolean deleted; +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayBenMapping.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayBenMapping.java new file mode 100644 index 00000000..61ab8036 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayBenMapping.java @@ -0,0 +1,56 @@ +/* +* 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.admin.data.nikshay; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Data; + +/** + * Links a BenRegId to its details and address rows (db_identity.i_beneficiarymapping). + * Mapped read-only, with only the columns the Ni-kshay upload needs. db_identity + * is a separate database on the same MySQL server, so it's given as the catalog. + */ +@Data +@Entity +@Table(name = "i_beneficiarymapping", catalog = "db_identity") +public class NikshayBenMapping { + + @Id + @Column(name = "BenMapId") + private Long benMapId; + + @Column(name = "BenRegId") + private Long benRegId; + + @Column(name = "BenDetailsId") + private Long benDetailsId; + + @Column(name = "BenAddressId") + private Long benAddressId; + + @Column(name = "Deleted") + private Boolean deleted; +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayBenRegIdMapping.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayBenRegIdMapping.java new file mode 100644 index 00000000..492d170c --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayBenRegIdMapping.java @@ -0,0 +1,50 @@ +/* +* 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.admin.data.nikshay; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Data; + +/** + * BeneficiaryID -> BenRegId mapping (db_identity.m_beneficiaryregidmapping). + * Mapped read-only, with only the columns the Ni-kshay upload needs. db_identity + * is a separate database on the same MySQL server, so it's given as the catalog. + */ +@Data +@Entity +@Table(name = "m_beneficiaryregidmapping", catalog = "db_identity") +public class NikshayBenRegIdMapping { + + @Id + @Column(name = "BenRegId") + private Long benRegId; + + @Column(name = "BeneficiaryID") + private Long beneficiaryID; + + @Column(name = "Deleted") + private Boolean deleted; +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayJobHistoryLog.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayJobHistoryLog.java new file mode 100644 index 00000000..8eefaeee --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayJobHistoryLog.java @@ -0,0 +1,105 @@ +/* +* 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.admin.data.nikshay; + +import java.sql.Timestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; +import com.iemr.admin.service.nikshayjoblog.NikshayLogStatus; +import com.iemr.admin.service.nikshayjoblog.NikshayPhase; +import com.iemr.admin.service.nikshayjoblog.NikshayTriggerType; + +import lombok.Data; + +/** + * One outcome of one Ni-kshay phase for one beneficiary (nikshay_worker_job_history_log). + * Append-only: a retry adds a new row with the next attempt number. Never holds + * passwords, request bodies, names or phone numbers. + */ +@Data +@Entity +@Table(name = "nikshay_worker_job_history_log") +public class NikshayJobHistoryLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // BenRegId + @Column(name = "ben_id") + private Long benId; + + @Column(name = "episode_id") + private String episodeId; + + @Enumerated(EnumType.STRING) + @Column(name = "phase") + private NikshayPhase phase; + + // What the row is about, e.g. the diagnostic order's external_order_id for NAAT / X-ray + @Column(name = "reference_id") + private String referenceId; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private NikshayLogStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "reason") + private NikshayFailureReason reason; + + @Column(name = "message") + private String message; + + // JSON sent to Ni-kshay and its full answer, on a failed call + @Column(name = "request_payload") + private String requestPayload; + + @Column(name = "response_body") + private String responseBody; + + @Column(name = "job_run_id") + private String jobRunId; + + @Enumerated(EnumType.STRING) + @Column(name = "trigger_type") + private NikshayTriggerType triggerType; + + @Column(name = "triggered_by") + private String triggeredBy; + + @Column(name = "attempt") + private Integer attempt; + + @Column(name = "created_at", insertable = false, updatable = false) + private Timestamp createdAt; +} diff --git a/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayEnrollResult.java b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayEnrollResult.java new file mode 100644 index 00000000..a97defb5 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayEnrollResult.java @@ -0,0 +1,54 @@ +/* +* 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.admin.data.nikshayidgenerate; + +import lombok.Data; + +/** The outcome of enrolling one beneficiary in Ni-kshay. */ +@Data +public class NikshayEnrollResult { + + private Long beneficiaryId; + // The person's own Ni-kshay ID: the fresh or existing record, also when a new episode + // was created under it (the parent ID). Saved on i_beneficiarydetails.nikshayId + private String nikshayId; + // The episode to use for tests, contacts and transfer: the new episode's ID when one was + // created, otherwise the same as nikshayId + private String episodeId; + private NikshayLinkStatus linkStatus; + // Stage of the duplicate found in Ni-kshay at the time of the check; null when none + private String stageFound; + // NAAT device (m_nikshay_device_configuration.id) whose login enrolled the beneficiary + private Integer deviceId; + // Set by the flow before the enrolment is saved + private NikshayFlowScenario flowScenario; + + public NikshayEnrollResult(Long beneficiaryId, String nikshayId, String episodeId, NikshayLinkStatus linkStatus, + String stageFound, Integer deviceId) { + this.beneficiaryId = beneficiaryId; + this.nikshayId = nikshayId; + this.episodeId = episodeId; + this.linkStatus = linkStatus; + this.stageFound = stageFound; + this.deviceId = deviceId; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayErroredBeneficiaryDTO.java b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayErroredBeneficiaryDTO.java new file mode 100644 index 00000000..3a074d16 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayErroredBeneficiaryDTO.java @@ -0,0 +1,44 @@ +/* +* 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.admin.data.nikshayidgenerate; + +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +import lombok.Data; + +/** A beneficiary that cannot be enrolled in Ni-kshay yet, and why. */ +@Data +public class NikshayErroredBeneficiaryDTO { + + private Long beneficiaryId; + // The Ni-kshay phase that failed (NikshayPhase), e.g. ENROLMENT or CONTACTS + private String phase; + private NikshayFailureReason failureReason; + // What is wrong, by BRD section 13 rule, e.g. "V8: Phone must be 10 digits (found 98765)" + private String reason; + + public NikshayErroredBeneficiaryDTO(Long beneficiaryId, NikshayFailureReason failureReason, String reason) { + this.beneficiaryId = beneficiaryId; + this.failureReason = failureReason; + this.reason = reason; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayFlowScenario.java b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayFlowScenario.java new file mode 100644 index 00000000..9e02f518 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayFlowScenario.java @@ -0,0 +1,30 @@ +/* +* 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.admin.data.nikshayidgenerate; + +/** Which enrolment flow enrolled a beneficiary in Ni-kshay; saved on each episode row. */ +public enum NikshayFlowScenario { + // TB-positive index patient, or a household member added as their contact + POSITIVE, + // Household where every member is screened, every X-ray is normal and no one is TB confirmed + ALL_NEGATIVE +} diff --git a/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayHouseholdMemberDTO.java b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayHouseholdMemberDTO.java new file mode 100644 index 00000000..aa196e57 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayHouseholdMemberDTO.java @@ -0,0 +1,56 @@ +/* +* 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.admin.data.nikshayidgenerate; + +import java.sql.Timestamp; + +import lombok.Data; + +/** A household member of an index patient (same houseoldId), with their TB screening status. */ +@Data +public class NikshayHouseholdMemberDTO { + + private Long beneficiaryId; + private Long houseoldId; + + private String firstName; + private String lastName; + private Integer age; + // dd-MM-yyyy + private String dateOfBirth; + // Male / Female / Transgender + private String gender; + private String primaryPhone; + // Already in Ni-kshay when not null + private String nikshayId; + // SC / ST / Others + private String caste; + // From the latest screening, e.g. "Fever," or "Asymptomatic," + private String symptom; + // Reactive / Non-Reactive / Unknown + private String hivStatus; + + // True when the member has a tb_screening row (latest, not deleted) + private Boolean tbScreened; + // Visit date of that latest screening; null when not screened + private Timestamp screeningDate; +} diff --git a/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayIdGenerateBeneficiaryDTO.java b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayIdGenerateBeneficiaryDTO.java new file mode 100644 index 00000000..8845b8bf --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayIdGenerateBeneficiaryDTO.java @@ -0,0 +1,82 @@ +/* +* 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.admin.data.nikshayidgenerate; + +import java.util.ArrayList; +import java.util.List; + +import com.iemr.admin.data.nikshayphasestatus.NikshayBeneficiaryType; + +import lombok.Data; + +/** One row of the Ni-kshay ID generation eligibility query: a beneficiary to enrol in Ni-kshay. */ +@Data +public class NikshayIdGenerateBeneficiaryDTO { + + private Long beneficiaryId; + private Long houseoldId; + + private String firstName; + private String lastName; + private String fathersName; + private Integer age; + // dd-MM-yyyy + private String dateOfBirth; + // Male / Female / Transgender + private String gender; + private String primaryPhone; + private String address; + private String pincode; + private String area; + private String maritalStatus; + private String occupation; + private String socioeconomicStatus; + // SC / ST / Others + private String caste; + private String keyPopulation; + // Comma separated, ends with a comma, e.g. "Fever,Night sweats," or "Asymptomatic," + private String symptom; + // Reactive / Non-Reactive / Unknown + private String hivStatus; + + // Ni-kshay hierarchy: village (residence) and its facility, TU, district and state + private Long residenceHierarchyId; + // m_nikshay_village.NikshayVillageID (AMRIT's own number, used for the device mapping); + // null when the household village ID is not found in m_nikshay_village + private Integer nikshayVillageId; + private String nikshayVillageName; + // The village's ID in Ni-kshay, looked up on the portal by name before enrolment + private Long nikshayPortalVillageId; + // Ni-kshay's own IDs (NikshayCode of m_nikshay_state / district / tu / facility) + private Long nikshayStateId; + private Long nikshayDistrictId; + private Long nikshayTuId; + private Long selectedHierarchyId; + + // INDEX, CONTACT or ALL_NEGATIVE, for the phase status (nikshay_beneficiary_phase_status) + private NikshayBeneficiaryType beneficiaryType; + // For a CONTACT: the BenRegId of their index patient + private Long indexBeneficiaryId; + + // The other members of this beneficiary's household, with their TB screening status + private List householdMembers = new ArrayList<>(); +} diff --git a/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayIdGenerateVillageBatch.java b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayIdGenerateVillageBatch.java new file mode 100644 index 00000000..3a629c4d --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayIdGenerateVillageBatch.java @@ -0,0 +1,43 @@ +/* +* 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.admin.data.nikshayidgenerate; + +import java.util.ArrayList; +import java.util.List; + +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; + +import lombok.Data; + +/** One Ni-kshay village's beneficiaries, with the village's NAAT device login used to enrol them. */ +@Data +public class NikshayIdGenerateVillageBatch { + + private Integer nikshayVillageId; + private NikshayDeviceCredentialDTO credential; + private List beneficiaries = new ArrayList<>(); + + public NikshayIdGenerateVillageBatch(Integer nikshayVillageId, NikshayDeviceCredentialDTO credential) { + this.nikshayVillageId = nikshayVillageId; + this.credential = credential; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayLinkStatus.java b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayLinkStatus.java new file mode 100644 index 00000000..f24ece2a --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayidgenerate/NikshayLinkStatus.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.admin.data.nikshayidgenerate; + +/** How a beneficiary was linked to Ni-kshay at enrolment (BRD section 7). */ +public enum NikshayLinkStatus { + // No duplicate: a fresh Ni-kshay ID was created (case 1) + NEW, + // Duplicate is Presumptive - Open: the existing record is used, tests go to it (case 2.1) + LINKED, + // Duplicate is closed or has an outcome: a new episode was created under it (cases 2.2, 2.4, 2.6) + NEW_EPISODE, + // Duplicate is diagnosed (not on treatment) or on treatment: ID fetched only, no tests (cases 2.3, 2.5) + LINKED_NO_ACTION +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryStatusDTO.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryStatusDTO.java new file mode 100644 index 00000000..76cfb3b9 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryStatusDTO.java @@ -0,0 +1,45 @@ +/* +* 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.admin.data.nikshayphasestatus; + +import java.util.LinkedHashMap; +import java.util.Map; + +import lombok.Data; + +/** One beneficiary in the status list, with every phase. */ +@Data +public class NikshayBeneficiaryStatusDTO { + + private Long benRegId; + private Long beneficiaryId; + private String name; + // INDEX, CONTACT or ALL_NEGATIVE + private String beneficiaryType; + // For a CONTACT: their index patient + private Long indexBenRegId; + private String indexName; + // The beneficiary's Ni-kshay ID, from the ENROLMENT phase + private String nikshayId; + // Every phase, keyed by NikshayPhase name, in phase order + private Map phases = new LinkedHashMap<>(); +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryStatusPage.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryStatusPage.java new file mode 100644 index 00000000..3b6cbced --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryStatusPage.java @@ -0,0 +1,37 @@ +/* +* 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.admin.data.nikshayphasestatus; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +/** One page of the status list. */ +@Data +public class NikshayBeneficiaryStatusPage { + + private long total; + private int page; + private int size; + private List beneficiaries = new ArrayList<>(); +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryType.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryType.java new file mode 100644 index 00000000..b7067385 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayBeneficiaryType.java @@ -0,0 +1,32 @@ +/* +* 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.admin.data.nikshayphasestatus; + +/** How a beneficiary entered Ni-kshay ID generation. */ +public enum NikshayBeneficiaryType { + // TB-positive index patient + INDEX, + // Household member added as an index patient's contact + CONTACT, + // Member of an all-negative household + ALL_NEGATIVE +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhase.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhase.java new file mode 100644 index 00000000..2123ad36 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhase.java @@ -0,0 +1,52 @@ +/* +* 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.admin.data.nikshayphasestatus; + +/** + * A Ni-kshay phase a beneficiary goes through, in the order the phases are run. Each phase + * is run on its own (POST /nikshay/phase/{phase}/run) and picks the beneficiaries waiting in + * it from nikshay_beneficiary_phase_status. logPhase is the phase's name in the job history + * log (nikshay_worker_job_history_log). + */ +public enum NikshayPhase { + // The beneficiary's Ni-kshay ID (index patient, contact or all-negative member) + ENROLMENT(com.iemr.admin.service.nikshayjoblog.NikshayPhase.NIKSHAY_ID_GENERATION), + // The TrueNat results, MTB and Rif resistance (MTB-RIF), added to the beneficiary's + // latest episode + NAAT_UPLOAD(com.iemr.admin.service.nikshayjoblog.NikshayPhase.NAAT), + // The chest X-ray result, added to the beneficiary's latest episode + XRAY_UPLOAD(com.iemr.admin.service.nikshayjoblog.NikshayPhase.XRAY), + // The index patient's household counts and contacts + CONTACTS(com.iemr.admin.service.nikshayjoblog.NikshayPhase.CONTACT_TPT), + // The episode moved to the village's designated PHI, once the tests are uploaded + TRANSFER_OUT(com.iemr.admin.service.nikshayjoblog.NikshayPhase.TRANSFER_OUT); + + private final com.iemr.admin.service.nikshayjoblog.NikshayPhase logPhase; + + NikshayPhase(com.iemr.admin.service.nikshayjoblog.NikshayPhase logPhase) { + this.logPhase = logPhase; + } + + public com.iemr.admin.service.nikshayjoblog.NikshayPhase getLogPhase() { + return logPhase; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseCandidate.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseCandidate.java new file mode 100644 index 00000000..5db2c2c1 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseCandidate.java @@ -0,0 +1,39 @@ +/* +* 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.admin.data.nikshayphasestatus; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** A beneficiary picked for a phase run, from their nikshay_beneficiary_phase_status row. */ +@Data +@AllArgsConstructor +public class NikshayPhaseCandidate { + private Long benRegId; + // BeneficiaryID (db_identity.m_beneficiaryregidmapping); null if not found + private Long beneficiaryId; + private NikshayBeneficiaryType beneficiaryType; + // For a CONTACT: BenRegId of their index patient + private Long indexBenRegId; + // Status of the row the beneficiary was picked by (null when the row does not exist) + private String status; +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseDetailDTO.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseDetailDTO.java new file mode 100644 index 00000000..db4a5c27 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseDetailDTO.java @@ -0,0 +1,41 @@ +/* +* 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.admin.data.nikshayphasestatus; + +import java.sql.Timestamp; + +import lombok.Data; + +/** One phase of a beneficiary, as shown in the status list. */ +@Data +public class NikshayPhaseDetailDTO { + + // NikshayPhaseStatus, or NOT_STARTED when the phase has no row + private String status; + private String failureReason; + private String errorDetail; + private Integer attempts; + private String nikshayId; + private String episodeId; + private Timestamp lastAttemptAt; + private Timestamp completedAt; +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseRunSettingDTO.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseRunSettingDTO.java new file mode 100644 index 00000000..d19d9a79 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseRunSettingDTO.java @@ -0,0 +1,38 @@ +/* +* 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.admin.data.nikshayphasestatus; + +import java.sql.Timestamp; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** A phase's run mode, from nikshay_phase_run_setting. */ +@Data +@AllArgsConstructor +public class NikshayPhaseRunSettingDTO { + private NikshayPhase phase; + private NikshayRunMode runMode; + // Username of the last change ("system" for the migration's defaults) + private String updatedBy; + private Timestamp updatedAt; +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseStatus.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseStatus.java new file mode 100644 index 00000000..1dc36233 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseStatus.java @@ -0,0 +1,35 @@ +/* +* 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.admin.data.nikshayphasestatus; + +/** Where a beneficiary is in one phase. A phase with no row has not started. */ +public enum NikshayPhaseStatus { + // Due, not tried yet (e.g. the household is not fully screened) + PENDING, + // The job is working on it + IN_PROGRESS, + DONE, + // Tried and failed; the row has the reason and the error + FAILED, + // The phase does not apply (e.g. contacts of an all-negative member) + NOT_APPLICABLE +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseStatusCount.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseStatusCount.java new file mode 100644 index 00000000..20e4d5d4 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayPhaseStatusCount.java @@ -0,0 +1,35 @@ +/* +* 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.admin.data.nikshayphasestatus; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** How many beneficiaries are in one status of one phase. */ +@Data +@AllArgsConstructor +public class NikshayPhaseStatusCount { + + private String phase; + private String status; + private long count; +} diff --git a/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayRunMode.java b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayRunMode.java new file mode 100644 index 00000000..1e66e47c --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayphasestatus/NikshayRunMode.java @@ -0,0 +1,30 @@ +/* +* 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.admin.data.nikshayphasestatus; + +/** + * How a Ni-kshay phase is run (BRD FR-19): AUTOMATIC by the scheduled job and by manual + * push, MANUAL by manual push only. + */ +public enum NikshayRunMode { + AUTOMATIC, MANUAL +} diff --git a/src/main/java/com/iemr/admin/data/nikshaytransferout/NikshayContactTestsDTO.java b/src/main/java/com/iemr/admin/data/nikshaytransferout/NikshayContactTestsDTO.java new file mode 100644 index 00000000..31fac544 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshaytransferout/NikshayContactTestsDTO.java @@ -0,0 +1,38 @@ +/* +* 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.admin.data.nikshaytransferout; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** What decides whether a contact is transferred out: their test results and symptoms. */ +@Data +@AllArgsConstructor +public class NikshayContactTestsDTO { + private Long benRegId; + // A completed TrueNat (MTB) result "TB Positive" + private boolean naatPositive; + // Their latest completed chest X-ray result, e.g. "Normal" or "TB Presumptive"; null if none + private String latestXray; + // Any symptom (cough, fever, weight loss, night sweats) in their latest TB screening + private boolean symptomatic; +} diff --git a/src/main/java/com/iemr/admin/data/nikshaytransferout/NikshayDesignatedPhiDTO.java b/src/main/java/com/iemr/admin/data/nikshaytransferout/NikshayDesignatedPhiDTO.java new file mode 100644 index 00000000..84182523 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshaytransferout/NikshayDesignatedPhiDTO.java @@ -0,0 +1,37 @@ +/* +* 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.admin.data.nikshaytransferout; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** A village's designated PHI for transfer out (m_nikshay_transfer_out_phi_village_mapping). */ +@Data +@AllArgsConstructor +public class NikshayDesignatedPhiDTO { + // m_nikshay_village.NikshayVillageID + private Integer villageId; + // The designated PHI's Ni-kshay ID (hierarchyMappingTo) + private String designatedPhiId; + // Its name in m_nikshay_facility, when found + private String phiName; +} diff --git a/src/main/java/com/iemr/admin/data/nikshayupload/NaatResultDTO.java b/src/main/java/com/iemr/admin/data/nikshayupload/NaatResultDTO.java new file mode 100644 index 00000000..73471bcd --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayupload/NaatResultDTO.java @@ -0,0 +1,80 @@ +/* +* 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.admin.data.nikshayupload; + +import java.sql.Timestamp; + +import lombok.Data; + +/** One completed NAAT (TrueNat MTB) test result for a beneficiary visit. */ +@Data +public class NaatResultDTO implements NikshayTestResult { + + private Long beneficiaryId; + private Long benRegId; + private String nikshayId; + // Beneficiary's current village (i_beneficiaryaddress.CurrVillageId / CurrVillage) + private Integer villageId; + private String village; + // Name of the same ID in m_nikshay_village. Differs from village when the ID was saved + // as an AMRIT village ID (registrations before the Stop TB fix of 2026-07-28). + private String nikshayVillageName; + // Beneficiary's current state as saved in AMRIT (i_beneficiaryaddress.CurrStateId) + private Integer stateId; + // Ni-kshay state code of the village, via m_nikshay_village -> facility -> TU -> district -> state + private String nikshayStateCode; + // Ni-kshay health facility (PHI) the village belongs to (m_nikshay_facility) + private String facilityName; + private String nikshayFacilityCode; + private Long visitCode; + private Integer vanID; + private String externalOrderId; + private String result; + private Boolean tbPresence; + private Timestamp orderedOn; + private Timestamp resultDate; + + // Used by the JPQL constructor expression in DiagnosticOrderRepo — keep the argument order in sync. + public NaatResultDTO(Long beneficiaryId, Long benRegId, String nikshayId, Integer villageId, String village, + String nikshayVillageName, Integer stateId, String nikshayStateCode, + String facilityName, String nikshayFacilityCode, Long visitCode, + Integer vanID, String externalOrderId, String result, Boolean tbPresence, Timestamp orderedOn, + Timestamp resultDate) { + this.beneficiaryId = beneficiaryId; + this.benRegId = benRegId; + this.nikshayId = nikshayId; + this.villageId = villageId; + this.village = village; + this.nikshayVillageName = nikshayVillageName; + this.stateId = stateId; + this.nikshayStateCode = nikshayStateCode; + this.facilityName = facilityName; + this.nikshayFacilityCode = nikshayFacilityCode; + this.visitCode = visitCode; + this.vanID = vanID; + this.externalOrderId = externalOrderId; + this.result = result; + this.tbPresence = tbPresence; + this.orderedOn = orderedOn; + this.resultDate = resultDate; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshayupload/NikshayDeviceCredentialDTO.java b/src/main/java/com/iemr/admin/data/nikshayupload/NikshayDeviceCredentialDTO.java new file mode 100644 index 00000000..348bf142 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayupload/NikshayDeviceCredentialDTO.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.admin.data.nikshayupload; + +import com.iemr.admin.data.nikshay.NikshayDeviceName; + +import lombok.Data; +import lombok.ToString; + +/** The device PHI login to use for a village. password is still encrypted. */ +@Data +@ToString(exclude = "password") +public class NikshayDeviceCredentialDTO { + + private Integer villageId; + private Integer deviceDetailId; + private String deviceSerialNumber; + private String deviceName; + private String phiUserId; + private String password; + // Testing facility for the device's tests: its Ni-kshay code and name (m_nikshay_facility) + private String facilityNikshayCode; + private String facilityName; + // The testing facility's district and state: Ni-kshay codes and names (m_nikshay_district / m_nikshay_state) + private String districtNikshayCode; + private String districtName; + private String stateNikshayCode; + private String stateName; + + // Used by the JPQL constructor expression in NikshayDeviceCredentialRepo — keep the argument order in sync. + public NikshayDeviceCredentialDTO(Integer villageId, Integer deviceDetailId, String deviceSerialNumber, NikshayDeviceName deviceName, + String phiUserId, String password, String facilityNikshayCode, String facilityName) { + this.villageId = villageId; + this.deviceDetailId = deviceDetailId; + this.deviceSerialNumber = deviceSerialNumber; + this.deviceName = deviceName == null ? null : deviceName.name(); + this.phiUserId = phiUserId; + this.password = password; + this.facilityNikshayCode = facilityNikshayCode; + this.facilityName = facilityName; + } + + // Used by the JPQL constructor expression in NikshayDeviceCredentialRepo — keep the argument order in sync. + public NikshayDeviceCredentialDTO(Integer villageId, Integer deviceDetailId, String deviceSerialNumber, NikshayDeviceName deviceName, + String phiUserId, String password, String facilityNikshayCode, String facilityName, String districtNikshayCode, + String districtName, String stateNikshayCode, String stateName) { + this(villageId, deviceDetailId, deviceSerialNumber, deviceName, phiUserId, password, facilityNikshayCode, + facilityName); + this.districtNikshayCode = districtNikshayCode; + this.districtName = districtName; + this.stateNikshayCode = stateNikshayCode; + this.stateName = stateName; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshayupload/NikshayTestResult.java b/src/main/java/com/iemr/admin/data/nikshayupload/NikshayTestResult.java new file mode 100644 index 00000000..e442359e --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayupload/NikshayTestResult.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.admin.data.nikshayupload; + +/** + * Fields the upload needs from a test result, whatever the test type + * (NaatResultDTO, XrayResultDTO). The Lombok getters implement it. + */ +public interface NikshayTestResult { + + Long getBeneficiaryId(); + + Long getBenRegId(); + + String getExternalOrderId(); + + Integer getVillageId(); + + String getVillage(); + + String getNikshayVillageName(); + + // Beneficiary's Ni-kshay ID (PatientId in Ni-kshay) + String getNikshayId(); + + // The phase upload sets it to the beneficiary's latest episode, where the test goes + void setNikshayId(String nikshayId); + + // EMR Lite result summary, e.g. "Normal" or "TB Positive" + String getResult(); + + // When the result was saved (tb_diagnostic_result.created_date): the test's date in Ni-kshay + java.sql.Timestamp getResultDate(); +} diff --git a/src/main/java/com/iemr/admin/data/nikshayupload/NikshayVillageBatch.java b/src/main/java/com/iemr/admin/data/nikshayupload/NikshayVillageBatch.java new file mode 100644 index 00000000..9d65aeea --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayupload/NikshayVillageBatch.java @@ -0,0 +1,46 @@ +/* +* 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.admin.data.nikshayupload; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +/** + * One village's results of a single test type (NaatResultDTO or XrayResultDTO), + * together with the device PHI login used to push them to Ni-kshay. + */ +@Data +public class NikshayVillageBatch { + + private Integer villageId; + private String villageName; + private NikshayDeviceCredentialDTO credential; + private List results = new ArrayList<>(); + + public NikshayVillageBatch(Integer villageId, String villageName, NikshayDeviceCredentialDTO credential) { + this.villageId = villageId; + this.villageName = villageName; + this.credential = credential; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshayupload/XrayResultDTO.java b/src/main/java/com/iemr/admin/data/nikshayupload/XrayResultDTO.java new file mode 100644 index 00000000..78dddda1 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshayupload/XrayResultDTO.java @@ -0,0 +1,81 @@ +/* +* 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.admin.data.nikshayupload; + +import java.sql.Timestamp; + +import lombok.Data; + +/** One completed chest X-ray result for a beneficiary visit. */ +@Data +public class XrayResultDTO implements NikshayTestResult { + + private Long beneficiaryId; + private Long benRegId; + private String nikshayId; + // Beneficiary's current village (i_beneficiaryaddress.CurrVillageId / CurrVillage) + private Integer villageId; + private String village; + // Name of the same ID in m_nikshay_village. Differs from village when the ID was saved + // as an AMRIT village ID (registrations before the Stop TB fix of 2026-07-28). + private String nikshayVillageName; + // Beneficiary's current state as saved in AMRIT (i_beneficiaryaddress.CurrStateId) + private Integer stateId; + // Ni-kshay state code of the village, via m_nikshay_village -> facility -> TU -> district -> state + private String nikshayStateCode; + // Ni-kshay health facility (PHI) the village belongs to (m_nikshay_facility) + private String facilityName; + private String nikshayFacilityCode; + private Long visitCode; + private Integer vanID; + private String externalOrderId; + // EMR Lite summary: "Normal" / "TB Presumptive" / "Abnormal but not TB Presumptive" + private String result; + private Boolean tbPresence; + private Timestamp orderedOn; + private Timestamp resultDate; + + // Used by the JPQL constructor expression in DiagnosticOrderRepo — keep the argument order in sync. + public XrayResultDTO(Long beneficiaryId, Long benRegId, String nikshayId, Integer villageId, String village, + String nikshayVillageName, Integer stateId, String nikshayStateCode, + String facilityName, String nikshayFacilityCode, Long visitCode, + Integer vanID, String externalOrderId, String result, Boolean tbPresence, Timestamp orderedOn, + Timestamp resultDate) { + this.beneficiaryId = beneficiaryId; + this.benRegId = benRegId; + this.nikshayId = nikshayId; + this.villageId = villageId; + this.village = village; + this.nikshayVillageName = nikshayVillageName; + this.stateId = stateId; + this.nikshayStateCode = nikshayStateCode; + this.facilityName = facilityName; + this.nikshayFacilityCode = nikshayFacilityCode; + this.visitCode = visitCode; + this.vanID = vanID; + this.externalOrderId = externalOrderId; + this.result = result; + this.tbPresence = tbPresence; + this.orderedOn = orderedOn; + this.resultDate = resultDate; + } +} diff --git a/src/main/java/com/iemr/admin/repo/nikshayidgenerate/NikshayIdGenerateRepository.java b/src/main/java/com/iemr/admin/repo/nikshayidgenerate/NikshayIdGenerateRepository.java new file mode 100644 index 00000000..c3daa676 --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshayidgenerate/NikshayIdGenerateRepository.java @@ -0,0 +1,553 @@ +/* +* 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.admin.repo.nikshayidgenerate; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.admin.data.nikshayidgenerate.NikshayEnrollResult; +import com.iemr.admin.data.nikshayidgenerate.NikshayFlowScenario; +import com.iemr.admin.data.nikshayidgenerate.NikshayHouseholdMemberDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayIdGenerateBeneficiaryDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayLinkStatus; + +/** + * Reads the beneficiaries that need a Ni-kshay ID: TB confirmed (or NAAT detected), alive, + * and with no Ni-kshay ID yet. Plain SQL through JdbcTemplate (as in NikshayExportRepository) + * because the query joins db_identity and db_iemr tables on the same MySQL server. + */ +@Repository +public class NikshayIdGenerateRepository { + + // Columns and joins of the eligibility query (Q1 of NIKSHAY_ENROLLMENT.sql), shared with + // the all-negative household query + private static final String BENEFICIARY_COLUMNS_SQL = "SELECT " + + " m.BenRegId AS beneficiary_id, " + + " hh.houseoldId, " + + " d.FirstName, " + + " NULLIF(TRIM(CONCAT_WS(' ', d.MiddleName, d.LastName)), '') AS LastName, " + + " NULLIF(TRIM(d.FatherName), '') AS FathersName, " + + " TIMESTAMPDIFF(YEAR, d.DOB, CURDATE()) AS Age, " + + " DATE_FORMAT(d.DOB, '%d-%m-%Y') AS DateOfBirth, " + + " CASE " + + " WHEN UPPER(IFNULL(d.Gender, '')) LIKE 'M%' THEN 'Male' " + + " WHEN UPPER(IFNULL(d.Gender, '')) LIKE 'F%' THEN 'Female' " + + " WHEN UPPER(IFNULL(d.Gender, '')) LIKE 'T%' THEN 'Transgender' " + + " ELSE NULL " + + " END AS Gender, " + + " COALESCE(c.PreferredPhoneNum, c.PhoneNum1, c.EmergencyContactNum) AS PrimaryPhone, " + + " NULLIF(TRIM(ad.PermAddrLine1), '') AS Address, " + + " NULLIF(TRIM(ad.PermPinCode), '') AS Pincode, " + + " COALESCE(NULLIF(TRIM(hh.residentialArea), ''), 'Unknown') AS Area, " + + " COALESCE(NULLIF(TRIM(d.MaritalStatus), ''), 'Unknown') AS MaritalStatus, " + + " COALESCE(NULLIF(TRIM(d.occupation), ''), 'Unknown') AS Occupation, " + + " COALESCE(NULLIF(TRIM(hh.type_bpl_apl), ''), 'Unknown') AS SocioeconomicStatus, " + + " CASE " + + " WHEN UPPER(TRIM(IFNULL(d.community, ''))) IN ('SC', 'ST') THEN UPPER(TRIM(d.community)) " + + " ELSE 'Others' " + + " END AS caste, " + + " 'Not Applicable' AS KeyPopulation, " + + " CASE " + + " WHEN CONCAT_WS(',', " + + " IF(LOWER(TRIM(IFNULL(scr.cough_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Cough for more than 2 weeks', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.fever_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Fever', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.weight_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Weight loss', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.sweats_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Night sweats', NULL) " + + " ) = '' THEN 'Asymptomatic,' " + + " ELSE CONCAT(CONCAT_WS(',', " + + " IF(LOWER(TRIM(IFNULL(scr.cough_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Cough for more than 2 weeks', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.fever_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Fever', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.weight_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Weight loss', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.sweats_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Night sweats', NULL) " + + " ), ',') " + + " END AS Symptom, " + + " CASE " + + " WHEN UPPER(IFNULL(scr.hiv_status, '')) IN ('POSITIVE', 'REACTIVE', 'R') THEN 'Reactive' " + + " WHEN UPPER(IFNULL(scr.hiv_status, '')) IN ('NEGATIVE', 'NON-REACTIVE', 'NON REACTIVE', 'NR') THEN 'Non-Reactive' " + + " ELSE 'Unknown' " + + " END AS HIVStatus, " + // The master's ID columns are AMRIT's own numbers; Ni-kshay's IDs are in NikshayCode. + // The village has no code: its Ni-kshay ID is looked up by name on the portal + + " hh.villageid AS ResidenceHierarchyId, " + + " nv.NikshayVillageID AS nikshay_village_id, " + + " nv.VillageName AS nikshay_village_name, " + + " CAST(NULLIF(TRIM(ns.NikshayCode), '') AS UNSIGNED) AS nikshay_state_id, " + + " CAST(NULLIF(TRIM(nd.NikshayCode), '') AS UNSIGNED) AS nikshay_district_id, " + + " CAST(NULLIF(TRIM(nt.NikshayCode), '') AS UNSIGNED) AS nikshay_tu_id, " + + " CAST(NULLIF(TRIM(nf.NikshayCode), '') AS UNSIGNED) AS SelectedHierarchyId " + + "FROM db_identity.i_beneficiarymapping m " + + "INNER JOIN db_identity.i_beneficiarydetails d " + + " ON d.VanSerialNo = m.BenDetailsId AND d.VanID = m.VanID AND d.Deleted = 0 " + + "INNER JOIN db_identity.i_beneficiaryaddress ad " + + " ON ad.BenAddressID = m.BenAddressID AND ad.Deleted = 0 " + + "LEFT JOIN db_identity.i_beneficiarycontacts c " + + " ON c.BenContactsID = m.BenContactsId " + + "LEFT JOIN db_iemr.tb_confirmed_cases conf " + + " ON conf.id = ( " + + " SELECT c2.id FROM db_iemr.tb_confirmed_cases c2 " + + " WHERE c2.benRegID = m.BenRegId ORDER BY c2.id DESC LIMIT 1) " + + "LEFT JOIN db_iemr.tb_suspected sus " + + " ON sus.id = ( " + + " SELECT x.id FROM db_iemr.tb_suspected x " + + " WHERE x.benRegID = m.BenRegId OR x.ben_id = m.BenRegId " + + " ORDER BY x.id DESC LIMIT 1) " + + "LEFT JOIN db_iemr.tb_stoptb_diagnostics diag " + + " ON diag.id = ( " + + " SELECT d2.id FROM db_iemr.tb_stoptb_diagnostics d2 " + + " WHERE d2.ben_reg_id = m.BenRegId AND d2.deleted = 0 " + + " ORDER BY d2.id DESC LIMIT 1) " + + "LEFT JOIN db_iemr.tb_screening scr " + + " ON scr.id = ( " + + " SELECT s2.id FROM db_iemr.tb_screening s2 " + + " WHERE s2.ben_reg_id = m.BenRegId AND s2.deleted = 0 " + + " ORDER BY s2.id DESC LIMIT 1) " + + "LEFT JOIN db_identity.i_beneficiarydetails_rmnch rm " + + " ON rm.beneficiaryDetails_RmnchId = ( " + + " SELECT MAX(r2.beneficiaryDetails_RmnchId) " + + " FROM db_identity.i_beneficiarydetails_rmnch r2 " + + " WHERE r2.BeneficiaryRegID = m.BenRegId " + + " AND r2.houseoldId IS NOT NULL " + + " AND (r2.Deleted = 0 OR r2.Deleted IS NULL) " + + " ) " + + "LEFT JOIN db_identity.i_householddetails hh " + + " ON hh.houseHoldDetailsId = ( " + + " SELECT MAX(h2.houseHoldDetailsId) " + + " FROM db_identity.i_householddetails h2 " + + " WHERE h2.houseoldId = rm.houseoldId " + + " AND (h2.Deleted = 0 OR h2.Deleted IS NULL) " + + " ) " + + "LEFT JOIN db_iemr.m_nikshay_village nv " + + " ON nv.NikshayVillageID = hh.villageid " + + " AND (nv.Deleted = 0 OR nv.Deleted IS NULL) " + + "LEFT JOIN db_iemr.m_nikshay_facility nf " + + " ON nf.NikshayFacilityID = nv.NikshayFacilityID " + + " AND (nf.Deleted = 0 OR nf.Deleted IS NULL) " + + "LEFT JOIN db_iemr.m_nikshay_tu nt " + + " ON nt.NikshayTUID = nf.NikshayTUID " + + " AND (nt.Deleted = 0 OR nt.Deleted IS NULL) " + + "LEFT JOIN db_iemr.m_nikshay_district nd " + + " ON nd.NikshayDistrictID = nt.NikshayDistrictID " + + " AND (nd.Deleted = 0 OR nd.Deleted IS NULL) " + + "LEFT JOIN db_iemr.m_nikshay_state ns " + + " ON ns.NikshayStateID = nd.NikshayStateID " + + " AND (ns.Deleted = 0 OR ns.Deleted IS NULL) "; + + private static final String ELIGIBLE_BENEFICIARIES_SQL = BENEFICIARY_COLUMNS_SQL + + "WHERE m.Deleted = 0 " + + " AND m.BenRegId IS NOT NULL " + + " AND IFNULL(d.is_death, 0) = 0 " + + " AND ( " + + " conf.benRegID IS NOT NULL " + + " OR IFNULL(sus.is_tb_confirmed, 0) = 1 " + + " OR IFNULL(sus.is_confirmed, 0) = 1 " + + " OR ( " + + " LOWER(IFNULL(COALESCE(diag.truenat_result, sus.sputum_test_result), '')) LIKE '%detect%' " + + " AND LOWER(IFNULL(COALESCE(diag.truenat_result, sus.sputum_test_result), '')) NOT LIKE '%not detect%' " + + " ) " + + " ) " + + " AND IFNULL(NULLIF(TRIM(d.nikshayId), ''), '') = '' " + + " AND IFNULL(NULLIF(TRIM(sus.nikshay_id), ''), '') = '' " + + " AND IFNULL(NULLIF(TRIM(diag.nikshay_id), ''), '') = '' " + + "ORDER BY m.BenRegId DESC"; + + // "M. Tb detected" in a TrueNat / sputum result column (same check as the eligibility query) + private static String detected(String column) { + return "(LOWER(IFNULL(" + column + ", '')) LIKE '%detect%' " + + "AND LOWER(IFNULL(" + column + ", '')) NOT LIKE '%not detect%')"; + } + + // Latest X-ray result of household member "mem" from the Stop TB diagnostics form + private static final String FORM_XRAY_SQL = "(SELECT dx.digital_chest_xray_result " + + "FROM db_iemr.tb_stoptb_diagnostics dx " + + "WHERE dx.ben_reg_id = mem.BeneficiaryRegID AND dx.deleted = 0 " + + " AND NULLIF(TRIM(dx.digital_chest_xray_result), '') IS NOT NULL " + + "ORDER BY dx.id DESC LIMIT 1)"; + + // Latest completed chest X-ray order result of "mem" (the result the X-ray upload sends) + private static final String ORDER_XRAY_SQL = "(SELECT rx.result_summary " + + "FROM db_iemr.tb_diagnostic_order ox " + + "JOIN db_iemr.tb_diagnostic_result rx " + + " ON rx.external_order_id = ox.external_order_id AND rx.deleted = 0 " + + "JOIN db_identity.m_beneficiaryregidmapping bx " + + " ON bx.BeneficiaryID = ox.beneficiary_id AND bx.Deleted = 0 " + + "WHERE bx.BenRegId = mem.BeneficiaryRegID AND ox.order_type = 'XRAY_CHEST' " + + " AND ox.status = 'COMPLETED' AND ox.deleted = 0 " + + " AND NULLIF(TRIM(rx.result_summary), '') IS NOT NULL " + + "ORDER BY ox.created_date DESC, ox.id DESC LIMIT 1)"; + + // A member of the household (each from their latest rmnch row) who stops it from being + // all negative: not TB screened, ever TB confirmed or M. Tb detected, or no normal X-ray. + // The X-ray is normal when at least one of the two results says Normal and neither + // says anything else + private static final String MEMBER_NOT_CLEARED_SQL = "SELECT 1 FROM db_identity.i_beneficiarydetails_rmnch mem " + + "WHERE mem.houseoldId = rm.houseoldId " + + " AND mem.BeneficiaryRegID IS NOT NULL " + + " AND (mem.Deleted = 0 OR mem.Deleted IS NULL) " + + " AND mem.beneficiaryDetails_RmnchId = ( " + + " SELECT MAX(r3.beneficiaryDetails_RmnchId) " + + " FROM db_identity.i_beneficiarydetails_rmnch r3 " + + " WHERE r3.BeneficiaryRegID = mem.BeneficiaryRegID " + + " AND r3.houseoldId IS NOT NULL " + + " AND (r3.Deleted = 0 OR r3.Deleted IS NULL) " + + " ) " + + " AND ( " + + " NOT EXISTS (SELECT 1 FROM db_iemr.tb_screening s3 " + + " WHERE s3.ben_reg_id = mem.BeneficiaryRegID AND s3.deleted = 0) " + + " OR EXISTS (SELECT 1 FROM db_iemr.tb_confirmed_cases c3 " + + " WHERE c3.benRegID = mem.BeneficiaryRegID) " + + " OR EXISTS (SELECT 1 FROM db_iemr.tb_suspected x3 " + + " WHERE (x3.benRegID = mem.BeneficiaryRegID OR x3.ben_id = mem.BeneficiaryRegID) " + + " AND (IFNULL(x3.is_tb_confirmed, 0) = 1 OR IFNULL(x3.is_confirmed, 0) = 1 " + + " OR " + detected("x3.sputum_test_result") + ")) " + + " OR EXISTS (SELECT 1 FROM db_iemr.tb_stoptb_diagnostics d3 " + + " WHERE d3.ben_reg_id = mem.BeneficiaryRegID AND d3.deleted = 0 " + + " AND " + detected("d3.truenat_result") + ") " + + " OR NOT ( " + + " (" + FORM_XRAY_SQL + " IS NOT NULL OR " + ORDER_XRAY_SQL + " IS NOT NULL) " + + " AND LOWER(TRIM(IFNULL(" + FORM_XRAY_SQL + ", 'normal'))) = 'normal' " + + " AND LOWER(TRIM(IFNULL(" + ORDER_XRAY_SQL + ", 'normal'))) = 'normal' " + + " ) " + + " )"; + + // Members of all-negative households who have no Ni-kshay ID yet: every member of the + // household is TB screened, has a normal X-ray and is not TB confirmed + private static final String ALL_NEGATIVE_BENEFICIARIES_SQL = BENEFICIARY_COLUMNS_SQL + + "WHERE m.Deleted = 0 " + + " AND m.BenRegId IS NOT NULL " + + " AND IFNULL(d.is_death, 0) = 0 " + + " AND rm.houseoldId IS NOT NULL " + + " AND IFNULL(NULLIF(TRIM(d.nikshayId), ''), '') = '' " + + " AND IFNULL(NULLIF(TRIM(sus.nikshay_id), ''), '') = '' " + + " AND IFNULL(NULLIF(TRIM(diag.nikshay_id), ''), '') = '' " + + " AND NOT EXISTS (" + MEMBER_NOT_CLEARED_SQL + ") " + + "ORDER BY m.BenRegId DESC"; + + // The given beneficiaries with the eligibility query's columns, whether or not they have a + // Ni-kshay ID (the CONTACTS phase reads index patients enrolled earlier) + private static final String BENEFICIARIES_BY_BEN_REG_IDS_SQL = BENEFICIARY_COLUMNS_SQL + + "WHERE m.Deleted = 0 " + + " AND m.BenRegId IN (:benRegIds) " + + "ORDER BY m.BenRegId"; + + // Each beneficiary's latest episode row (V133): the episode tests and contacts go to + private static final String LATEST_EPISODES_SQL = "SELECT e.ben_reg_id, e.nikshay_id, e.episode_id, " + + "e.link_status, e.flow_scenario, e.stage_found, e.device_id " + + "FROM db_iemr.nikshay_beneficiary_episode e " + + "WHERE e.id IN ( " + + " SELECT MAX(e2.id) FROM db_iemr.nikshay_beneficiary_episode e2 " + + " WHERE e2.ben_reg_id IN (:benRegIds) GROUP BY e2.ben_reg_id)"; + + // Members of the given households, each from their latest household (rmnch) row, with their + // latest tb_screening row (also used for their symptoms when added as a contact). Same member + // joins as Q3 / screening join as Q2 of NIKSHAY_ENROLLMENT.sql. + private static final String HOUSEHOLD_MEMBERS_SQL = "SELECT " + + " rm.houseoldId, " + + " rm.BeneficiaryRegID AS beneficiary_id, " + + " COALESCE(d.FirstName, d2.FirstName) AS FirstName, " + + " NULLIF(TRIM(CONCAT_WS(' ', COALESCE(d.MiddleName, d2.MiddleName), COALESCE(d.LastName, d2.LastName))), '') AS LastName, " + + " TIMESTAMPDIFF(YEAR, COALESCE(d.DOB, d2.DOB), CURDATE()) AS Age, " + + " DATE_FORMAT(COALESCE(d.DOB, d2.DOB), '%d-%m-%Y') AS DateOfBirth, " + + " CASE " + + " WHEN UPPER(IFNULL(COALESCE(d.Gender, d2.Gender), '')) LIKE 'M%' THEN 'Male' " + + " WHEN UPPER(IFNULL(COALESCE(d.Gender, d2.Gender), '')) LIKE 'F%' THEN 'Female' " + + " WHEN UPPER(IFNULL(COALESCE(d.Gender, d2.Gender), '')) LIKE 'T%' THEN 'Transgender' " + + " ELSE NULL " + + " END AS Gender, " + + " COALESCE(c.PreferredPhoneNum, c.PhoneNum1, c.EmergencyContactNum) AS PrimaryPhone, " + + " NULLIF(TRIM(COALESCE(d.nikshayId, d2.nikshayId)), '') AS NikshayId, " + + " CASE " + + " WHEN UPPER(TRIM(IFNULL(COALESCE(d.community, d2.community), ''))) IN ('SC', 'ST') " + + " THEN UPPER(TRIM(COALESCE(d.community, d2.community))) " + + " ELSE 'Others' " + + " END AS caste, " + // Same symptom and HIV mapping as the eligibility query + + " CASE " + + " WHEN CONCAT_WS(',', " + + " IF(LOWER(TRIM(IFNULL(scr.cough_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Cough for more than 2 weeks', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.fever_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Fever', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.weight_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Weight loss', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.sweats_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Night sweats', NULL) " + + " ) = '' THEN 'Asymptomatic,' " + + " ELSE CONCAT(CONCAT_WS(',', " + + " IF(LOWER(TRIM(IFNULL(scr.cough_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Cough for more than 2 weeks', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.fever_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Fever', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.weight_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Weight loss', NULL), " + + " IF(LOWER(TRIM(IFNULL(scr.sweats_check, ''))) IN ('1', 'yes', 'y', 'true'), 'Night sweats', NULL) " + + " ), ',') " + + " END AS Symptom, " + + " CASE " + + " WHEN UPPER(IFNULL(scr.hiv_status, '')) IN ('POSITIVE', 'REACTIVE', 'R') THEN 'Reactive' " + + " WHEN UPPER(IFNULL(scr.hiv_status, '')) IN ('NEGATIVE', 'NON-REACTIVE', 'NON REACTIVE', 'NR') THEN 'Non-Reactive' " + + " ELSE 'Unknown' " + + " END AS HIVStatus, " + + " IF(scr.id IS NULL, 0, 1) AS TbScreened, " + + " scr.visit_date AS ScreeningDate " + + "FROM db_identity.i_beneficiarydetails_rmnch rm " + + "LEFT JOIN db_identity.i_beneficiarydetails d " + + " ON d.BeneficiaryRegID = rm.BeneficiaryRegID AND (d.Deleted = 0 OR d.Deleted IS NULL) " + + "LEFT JOIN db_identity.i_beneficiarymapping map " + + " ON map.BenRegId = rm.BeneficiaryRegID AND map.Deleted = 0 " + + "LEFT JOIN db_identity.i_beneficiarydetails d2 " + + " ON d2.VanSerialNo = map.BenDetailsId AND d2.VanID = map.VanID AND d2.Deleted = 0 " + + "LEFT JOIN db_identity.i_beneficiarycontacts c " + + " ON c.BenContactsID = map.BenContactsId " + + "LEFT JOIN db_iemr.tb_screening scr " + + " ON scr.id = ( " + + " SELECT s2.id FROM db_iemr.tb_screening s2 " + + " WHERE s2.ben_reg_id = rm.BeneficiaryRegID AND s2.deleted = 0 " + + " ORDER BY s2.id DESC LIMIT 1) " + + "WHERE rm.houseoldId IN (:householdIds) " + + " AND rm.BeneficiaryRegID IS NOT NULL " + + " AND (rm.Deleted = 0 OR rm.Deleted IS NULL) " + + " AND rm.beneficiaryDetails_RmnchId = ( " + + " SELECT MAX(r2.beneficiaryDetails_RmnchId) " + + " FROM db_identity.i_beneficiarydetails_rmnch r2 " + + " WHERE r2.BeneficiaryRegID = rm.BeneficiaryRegID " + + " AND r2.houseoldId IS NOT NULL " + + " AND (r2.Deleted = 0 OR r2.Deleted IS NULL) " + + " ) " + + "ORDER BY rm.houseoldId, rm.BeneficiaryRegID"; + + // Q5 of NIKSHAY_ENROLLMENT.sql: write the Ni-kshay ID back to the beneficiary, and to their + // tb_suspected / tb_stoptb_diagnostics rows that don't have one yet + private static final String SAVE_ID_ON_BEN_DETAILS_SQL = "UPDATE db_identity.i_beneficiarydetails " + + "SET nikshayId = :nikshayId, nikshayCreatedByAmrit = :createdByAmrit " + + "WHERE VanSerialNo = ( " + + " SELECT m.BenDetailsId FROM db_identity.i_beneficiarymapping m " + + " WHERE m.BenRegId = :benRegId AND m.Deleted = 0 LIMIT 1) " + + " AND VanID = ( " + + " SELECT m.VanID FROM db_identity.i_beneficiarymapping m " + + " WHERE m.BenRegId = :benRegId AND m.Deleted = 0 LIMIT 1)"; + + private static final String SAVE_ID_ON_TB_SUSPECTED_SQL = "UPDATE db_iemr.tb_suspected " + + "SET nikshay_id = :nikshayId, nikshay_created_by_amrit = :createdByAmrit " + + "WHERE (benRegID = :benRegId OR ben_id = :benRegId) " + + " AND (nikshay_id IS NULL OR TRIM(nikshay_id) = '')"; + + // One row per enrolment (V133); BeneficiaryID is looked up from the BenRegId + private static final String ADD_EPISODE_SQL = "INSERT INTO db_iemr.nikshay_beneficiary_episode " + + "(beneficiary_id, ben_reg_id, nikshay_id, episode_id, link_status, flow_scenario, stage_found, device_id, " + + "created_by) " + + "VALUES (( " + + " SELECT r.BeneficiaryID FROM db_identity.m_beneficiaryregidmapping r " + + " WHERE r.BenRegId = :benRegId AND r.Deleted = 0 LIMIT 1), " + + " :benRegId, :nikshayId, :episodeId, :linkStatus, :flowScenario, :stageFound, :deviceId, :createdBy)"; + + private static final String SAVE_ID_ON_DIAGNOSTICS_SQL = "UPDATE db_iemr.tb_stoptb_diagnostics " + + "SET nikshay_id = :nikshayId " + + "WHERE ben_reg_id = :benRegId AND deleted = 0 " + + " AND (nikshay_id IS NULL OR TRIM(nikshay_id) = '')"; + + @Autowired + private DataSource dataSource; + + /** + * Returns every beneficiary that is eligible for a Ni-kshay ID, each with the other + * members of their household and their TB screening status. + */ + public List findEligibleBeneficiaries() { + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + List beneficiaries = jdbcTemplate.query(ELIGIBLE_BENEFICIARIES_SQL, + (rs, rowNum) -> mapRow(rs)); + addHouseholdMembers(beneficiaries); + return beneficiaries; + } + + /** + * Returns the members of all-negative households (every member TB screened, every X-ray + * normal, no one TB confirmed) who have no Ni-kshay ID yet. No household members are + * attached: there is no index patient, so no contacts. + */ + public List findAllNegativeBeneficiaries() { + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + return jdbcTemplate.query(ALL_NEGATIVE_BENEFICIARIES_SQL, (rs, rowNum) -> mapRow(rs)); + } + + /** + * Returns the given beneficiaries (by BenRegId), each with the other members of their + * household, whether or not they have a Ni-kshay ID. + */ + public List findBeneficiariesByBenRegIds(List benRegIds) { + if (benRegIds.isEmpty()) { + return new ArrayList<>(); + } + NamedParameterJdbcTemplate namedJdbcTemplate = new NamedParameterJdbcTemplate(dataSource); + List beneficiaries = namedJdbcTemplate.query(BENEFICIARIES_BY_BEN_REG_IDS_SQL, + new MapSqlParameterSource("benRegIds", benRegIds), (rs, rowNum) -> mapRow(rs)); + addHouseholdMembers(beneficiaries); + return beneficiaries; + } + + /** + * Returns each given beneficiary's latest enrolment (nikshay_beneficiary_episode), by + * BenRegId. A beneficiary never enrolled has no entry. + */ + public Map findLatestEpisodes(List benRegIds) { + Map episodes = new HashMap<>(); + if (benRegIds.isEmpty()) { + return episodes; + } + NamedParameterJdbcTemplate namedJdbcTemplate = new NamedParameterJdbcTemplate(dataSource); + List rows = namedJdbcTemplate.query(LATEST_EPISODES_SQL, + new MapSqlParameterSource("benRegIds", benRegIds), (rs, rowNum) -> mapEpisodeRow(rs)); + for (NikshayEnrollResult row : rows) { + episodes.put(row.getBeneficiaryId(), row); + } + return episodes; + } + + private NikshayEnrollResult mapEpisodeRow(ResultSet rs) throws SQLException { + NikshayEnrollResult result = new NikshayEnrollResult(rs.getObject("ben_reg_id", Long.class), + rs.getString("nikshay_id"), rs.getString("episode_id"), NikshayLinkStatus.valueOf(rs.getString("link_status")), + rs.getString("stage_found"), rs.getObject("device_id", Integer.class)); + result.setFlowScenario(NikshayFlowScenario.valueOf(rs.getString("flow_scenario"))); + return result; + } + + // Loads the members of all the beneficiaries' households in one query and adds each + // beneficiary's household members to it, leaving out the beneficiary themself. + private void addHouseholdMembers(List beneficiaries) { + List householdIds = new ArrayList<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + if (beneficiary.getHouseoldId() != null && !householdIds.contains(beneficiary.getHouseoldId())) { + householdIds.add(beneficiary.getHouseoldId()); + } + } + if (householdIds.isEmpty()) { + return; + } + NamedParameterJdbcTemplate namedJdbcTemplate = new NamedParameterJdbcTemplate(dataSource); + List members = namedJdbcTemplate.query(HOUSEHOLD_MEMBERS_SQL, + new MapSqlParameterSource("householdIds", householdIds), (rs, rowNum) -> mapMemberRow(rs)); + + Map> membersByHousehold = new HashMap<>(); + for (NikshayHouseholdMemberDTO member : members) { + List householdMembers = membersByHousehold.get(member.getHouseoldId()); + if (householdMembers == null) { + householdMembers = new ArrayList<>(); + membersByHousehold.put(member.getHouseoldId(), householdMembers); + } + householdMembers.add(member); + } + + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + List householdMembers = membersByHousehold.get(beneficiary.getHouseoldId()); + if (householdMembers == null) { + continue; + } + for (NikshayHouseholdMemberDTO member : householdMembers) { + if (!member.getBeneficiaryId().equals(beneficiary.getBeneficiaryId())) { + beneficiary.getHouseholdMembers().add(member); + } + } + } + } + + /** + * Saves an enrolment: the person's Ni-kshay ID on the beneficiary (Q5) and a new + * nikshay_beneficiary_episode row, in one transaction. nikshayCreatedByAmrit is true + * only when TBMJA created the person's record (NEW). Returns the number of + * i_beneficiarydetails rows updated (0 when the identity record is not found). + */ + @Transactional + public int saveEnrollment(NikshayEnrollResult result) { + NamedParameterJdbcTemplate namedJdbcTemplate = new NamedParameterJdbcTemplate(dataSource); + MapSqlParameterSource params = new MapSqlParameterSource(); + params.addValue("benRegId", result.getBeneficiaryId()); + params.addValue("nikshayId", result.getNikshayId()); + params.addValue("createdByAmrit", result.getLinkStatus() == NikshayLinkStatus.NEW ? 1 : 0); + params.addValue("episodeId", result.getEpisodeId()); + params.addValue("linkStatus", result.getLinkStatus().name()); + params.addValue("flowScenario", result.getFlowScenario().name()); + params.addValue("stageFound", result.getStageFound()); + params.addValue("deviceId", result.getDeviceId()); + params.addValue("createdBy", "NikshayIdGeneration"); + int updated = namedJdbcTemplate.update(SAVE_ID_ON_BEN_DETAILS_SQL, params); + namedJdbcTemplate.update(SAVE_ID_ON_TB_SUSPECTED_SQL, params); + namedJdbcTemplate.update(SAVE_ID_ON_DIAGNOSTICS_SQL, params); + namedJdbcTemplate.update(ADD_EPISODE_SQL, params); + return updated; + } + + private NikshayHouseholdMemberDTO mapMemberRow(ResultSet rs) throws SQLException { + NikshayHouseholdMemberDTO member = new NikshayHouseholdMemberDTO(); + member.setBeneficiaryId(rs.getObject("beneficiary_id", Long.class)); + member.setHouseoldId(rs.getObject("houseoldId", Long.class)); + member.setFirstName(rs.getString("FirstName")); + member.setLastName(rs.getString("LastName")); + member.setAge(rs.getObject("Age", Integer.class)); + member.setDateOfBirth(rs.getString("DateOfBirth")); + member.setGender(rs.getString("Gender")); + member.setPrimaryPhone(rs.getString("PrimaryPhone")); + member.setNikshayId(rs.getString("NikshayId")); + member.setCaste(rs.getString("caste")); + member.setSymptom(rs.getString("Symptom")); + member.setHivStatus(rs.getString("HIVStatus")); + member.setTbScreened(rs.getInt("TbScreened") == 1); + member.setScreeningDate(rs.getTimestamp("ScreeningDate")); + return member; + } + + private NikshayIdGenerateBeneficiaryDTO mapRow(ResultSet rs) throws SQLException { + NikshayIdGenerateBeneficiaryDTO dto = new NikshayIdGenerateBeneficiaryDTO(); + dto.setBeneficiaryId(rs.getObject("beneficiary_id", Long.class)); + dto.setHouseoldId(rs.getObject("houseoldId", Long.class)); + dto.setFirstName(rs.getString("FirstName")); + dto.setLastName(rs.getString("LastName")); + dto.setFathersName(rs.getString("FathersName")); + dto.setAge(rs.getObject("Age", Integer.class)); + dto.setDateOfBirth(rs.getString("DateOfBirth")); + dto.setGender(rs.getString("Gender")); + dto.setPrimaryPhone(rs.getString("PrimaryPhone")); + dto.setAddress(rs.getString("Address")); + dto.setPincode(rs.getString("Pincode")); + dto.setArea(rs.getString("Area")); + dto.setMaritalStatus(rs.getString("MaritalStatus")); + dto.setOccupation(rs.getString("Occupation")); + dto.setSocioeconomicStatus(rs.getString("SocioeconomicStatus")); + dto.setCaste(rs.getString("caste")); + dto.setKeyPopulation(rs.getString("KeyPopulation")); + dto.setSymptom(rs.getString("Symptom")); + dto.setHivStatus(rs.getString("HIVStatus")); + dto.setResidenceHierarchyId(rs.getObject("ResidenceHierarchyId", Long.class)); + dto.setNikshayVillageId(rs.getObject("nikshay_village_id", Integer.class)); + dto.setNikshayVillageName(rs.getString("nikshay_village_name")); + dto.setNikshayStateId(rs.getObject("nikshay_state_id", Long.class)); + dto.setNikshayDistrictId(rs.getObject("nikshay_district_id", Long.class)); + dto.setNikshayTuId(rs.getObject("nikshay_tu_id", Long.class)); + dto.setSelectedHierarchyId(rs.getObject("SelectedHierarchyId", Long.class)); + return dto; + } +} diff --git a/src/main/java/com/iemr/admin/repo/nikshayjoblog/NikshayJobHistoryLogRepo.java b/src/main/java/com/iemr/admin/repo/nikshayjoblog/NikshayJobHistoryLogRepo.java new file mode 100644 index 00000000..10b289fb --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshayjoblog/NikshayJobHistoryLogRepo.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.admin.repo.nikshayjoblog; + +import java.sql.Timestamp; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; +import com.iemr.admin.service.nikshayjoblog.NikshayLogStatus; +import com.iemr.admin.service.nikshayjoblog.NikshayPhase; + +@Repository +public interface NikshayJobHistoryLogRepo extends CrudRepository { + + // Earlier attempts of the same beneficiary, phase and reference; the next attempt is this + 1. + // benId / referenceId can be null, so they're compared through COALESCE. + @Query(" SELECT COUNT(l) FROM NikshayJobHistoryLog l WHERE l.phase = :phase " + + " AND COALESCE(l.benId, -1) = COALESCE(:benId, -1) " + + " AND COALESCE(l.referenceId, '') = COALESCE(:referenceId, '') ") + long countAttempts(@Param("phase") NikshayPhase phase, @Param("benId") Long benId, + @Param("referenceId") String referenceId); + + // Latest entry of each (beneficiary, phase, reference), kept only when it failed: a later + // success drops the item from the list. Every filter is optional (null = no filter); + // toDate is exclusive. + String LATEST_FAILED_WHERE = " WHERE l.status = :failed " + + " AND l.id = (SELECT MAX(l2.id) FROM NikshayJobHistoryLog l2 WHERE l2.phase = l.phase " + + " AND COALESCE(l2.benId, -1) = COALESCE(l.benId, -1) " + + " AND COALESCE(l2.referenceId, '') = COALESCE(l.referenceId, '')) " + + " AND (:phase IS NULL OR l.phase = :phase) " + + " AND (:reason IS NULL OR l.reason = :reason) " + + " AND (:benId IS NULL OR l.benId = :benId) " + + " AND (:fromDate IS NULL OR l.createdAt >= :fromDate) " + + " AND (:toDate IS NULL OR l.createdAt < :toDate) "; + + @Query(value = " SELECT l FROM NikshayJobHistoryLog l " + LATEST_FAILED_WHERE + " ORDER BY l.id DESC ", + countQuery = " SELECT COUNT(l) FROM NikshayJobHistoryLog l " + LATEST_FAILED_WHERE) + Page findLatestFailed(@Param("failed") NikshayLogStatus failed, + @Param("phase") NikshayPhase phase, @Param("reason") NikshayFailureReason reason, + @Param("benId") Long benId, @Param("fromDate") Timestamp fromDate, @Param("toDate") Timestamp toDate, + Pageable pageable); + + List findByBenIdOrderByIdDesc(Long benId); + + // Which of the given references already have a successful upload in this phase + @Query(" SELECT DISTINCT l.referenceId FROM NikshayJobHistoryLog l WHERE l.phase = :phase " + + " AND l.status = :success AND l.referenceId IN :referenceIds ") + List findReferenceIdsWithStatus(@Param("phase") NikshayPhase phase, + @Param("success") NikshayLogStatus success, @Param("referenceIds") List referenceIds); +} diff --git a/src/main/java/com/iemr/admin/repo/nikshayphasestatus/NikshayPhaseRunSettingRepository.java b/src/main/java/com/iemr/admin/repo/nikshayphasestatus/NikshayPhaseRunSettingRepository.java new file mode 100644 index 00000000..1bfd857d --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshayphasestatus/NikshayPhaseRunSettingRepository.java @@ -0,0 +1,98 @@ +/* +* 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.admin.repo.nikshayphasestatus; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseRunSettingDTO; +import com.iemr.admin.data.nikshayphasestatus.NikshayRunMode; + +/** + * Reads and writes db_iemr.nikshay_phase_run_setting (AMRIT-DB V135): the run mode of each + * Ni-kshay phase. + */ +@Repository +public class NikshayPhaseRunSettingRepository { + + private static final String COLUMNS_SQL = "SELECT phase, run_mode, updated_by, updated_at " + + "FROM db_iemr.nikshay_phase_run_setting "; + + // Insert or update, so a phase with no row (e.g. added after V135) can be set too + private static final String SAVE_SQL = "INSERT INTO db_iemr.nikshay_phase_run_setting (phase, run_mode, updated_by) " + + "VALUES (:phase, :runMode, :updatedBy) " + + "ON DUPLICATE KEY UPDATE run_mode = VALUES(run_mode), updated_by = VALUES(updated_by), " + + "updated_at = CURRENT_TIMESTAMP"; + + @Autowired + private DataSource dataSource; + + /** Every phase's setting; phases not known to this code are left out. */ + public List findAll() { + List settings = new NamedParameterJdbcTemplate(dataSource) + .query(COLUMNS_SQL + "ORDER BY phase", (rs, rowNum) -> mapSetting(rs)); + settings.removeIf(setting -> setting.getPhase() == null); + return settings; + } + + /** The phase's setting, or null when it has no row. */ + public NikshayPhaseRunSettingDTO find(NikshayPhase phase) { + List settings = new NamedParameterJdbcTemplate(dataSource).query( + COLUMNS_SQL + "WHERE phase = :phase", new MapSqlParameterSource("phase", phase.name()), + (rs, rowNum) -> mapSetting(rs)); + return settings.isEmpty() ? null : settings.get(0); + } + + public void save(NikshayPhase phase, NikshayRunMode runMode, String updatedBy) { + MapSqlParameterSource params = new MapSqlParameterSource(); + params.addValue("phase", phase.name()); + params.addValue("runMode", runMode.name()); + params.addValue("updatedBy", updatedBy); + new NamedParameterJdbcTemplate(dataSource).update(SAVE_SQL, params); + } + + // An unknown phase or run mode is read as null + private static NikshayPhaseRunSettingDTO mapSetting(ResultSet rs) throws SQLException { + NikshayPhase phase = null; + NikshayRunMode runMode = null; + try { + phase = NikshayPhase.valueOf(rs.getString("phase")); + } catch (IllegalArgumentException | NullPointerException e) { + // left null + } + try { + runMode = NikshayRunMode.valueOf(rs.getString("run_mode")); + } catch (IllegalArgumentException | NullPointerException e) { + // left null + } + return new NikshayPhaseRunSettingDTO(phase, runMode, rs.getString("updated_by"), rs.getTimestamp("updated_at")); + } +} diff --git a/src/main/java/com/iemr/admin/repo/nikshayphasestatus/NikshayPhaseStatusRepository.java b/src/main/java/com/iemr/admin/repo/nikshayphasestatus/NikshayPhaseStatusRepository.java new file mode 100644 index 00000000..0bc03a87 --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshayphasestatus/NikshayPhaseStatusRepository.java @@ -0,0 +1,534 @@ +/* +* 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.admin.repo.nikshayphasestatus; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; +import java.util.Map; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshayphasestatus.NikshayBeneficiaryType; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseCandidate; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseStatusCount; + +/** + * Reads and writes db_iemr.nikshay_beneficiary_phase_status (AMRIT-DB V134): one row per + * beneficiary per Ni-kshay phase, updated in place. Plain SQL through JdbcTemplate, as in + * NikshayIdGenerateRepository, because the status list joins db_identity for the names. + */ +@Repository +public class NikshayPhaseStatusRepository { + + // Insert or update the beneficiary's row for the phase. An attempt is counted when + // countAttempt is 1, except when the row is already IN_PROGRESS (that try was counted + // when it started). attempts is assigned before status so it still sees the old status. + private static final String SAVE_SQL = "INSERT INTO db_iemr.nikshay_beneficiary_phase_status " + + "(ben_reg_id, beneficiary_id, beneficiary_type, index_ben_reg_id, phase, status, failure_reason, " + + " error_detail, attempts, nikshay_id, episode_id, last_attempt_at, completed_at) " + + "VALUES (:benRegId, ( " + + " SELECT r.BeneficiaryID FROM db_identity.m_beneficiaryregidmapping r " + + " WHERE r.BenRegId = :benRegId AND r.Deleted = 0 LIMIT 1), " + + " :beneficiaryType, :indexBenRegId, :phase, :status, :failureReason, :errorDetail, :countAttempt, " + + " :nikshayId, :episodeId, :lastAttemptAt, :completedAt) " + + "ON DUPLICATE KEY UPDATE " + + " attempts = attempts + IF(:countAttempt = 1 AND status <> 'IN_PROGRESS', 1, 0), " + + " beneficiary_type = VALUES(beneficiary_type), " + + " index_ben_reg_id = COALESCE(VALUES(index_ben_reg_id), index_ben_reg_id), " + + " status = VALUES(status), " + + " failure_reason = VALUES(failure_reason), " + + " error_detail = VALUES(error_detail), " + + " nikshay_id = COALESCE(VALUES(nikshay_id), nikshay_id), " + + " episode_id = COALESCE(VALUES(episode_id), episode_id), " + + " last_attempt_at = COALESCE(VALUES(last_attempt_at), last_attempt_at), " + + " completed_at = VALUES(completed_at)"; + + // A beneficiary's name: the details row by BenRegId, or through the mapping (as in the + // household member query of NikshayIdGenerateRepository) + private static String nameJoins(String benRegIdColumn, String alias) { + return "LEFT JOIN db_identity.i_beneficiarydetails " + alias + "d ON " + alias + "d.BeneficiaryRegID = " + + benRegIdColumn + " AND (" + alias + "d.Deleted = 0 OR " + alias + "d.Deleted IS NULL) " + + "LEFT JOIN db_identity.i_beneficiarymapping " + alias + "m ON " + alias + "m.BenRegId = " + + benRegIdColumn + " AND " + alias + "m.Deleted = 0 " + + "LEFT JOIN db_identity.i_beneficiarydetails " + alias + "d2 ON " + alias + "d2.VanSerialNo = " + alias + + "m.BenDetailsId AND " + alias + "d2.VanID = " + alias + "m.VanID AND " + alias + "d2.Deleted = 0 "; + } + + private static String name(String alias) { + return "NULLIF(TRIM(CONCAT_WS(' ', COALESCE(" + alias + "d.FirstName, " + alias + "d2.FirstName), COALESCE(" + + alias + "d.MiddleName, " + alias + "d2.MiddleName), COALESCE(" + alias + "d.LastName, " + alias + + "d2.LastName))), '')"; + } + + private static final String PHASE_ROWS_SQL = "SELECT ben_reg_id, phase, status, failure_reason, error_detail, " + + "attempts, nikshay_id, episode_id, last_attempt_at, completed_at " + + "FROM db_iemr.nikshay_beneficiary_phase_status WHERE ben_reg_id IN (:benRegIds)"; + + private static final String SUMMARY_SQL = "SELECT phase, status, COUNT(*) AS cnt " + + "FROM db_iemr.nikshay_beneficiary_phase_status GROUP BY phase, status ORDER BY phase, status"; + + // The queries below pick the beneficiaries waiting in a phase. A row is waiting when it is + // PENDING, IN_PROGRESS (left by a run that stopped), or FAILED with an error that is + // retried automatically: a missing field or an error / invalid test result (checked again + // every run, so a fix or a retest is picked up), or Ni-kshay down / login rejected while + // attempts < :maxAttempts. Other failures wait for a reprocess, which sets the row back to + // PENDING. Every phase needs the beneficiary's ENROLMENT to be DONE. Oldest change first. + + // CONTACTS: index patients waiting to have their household contacts added. + private static final String WAITING_FOR_CONTACTS_SQL = """ + SELECT ps.ben_reg_id, ps.beneficiary_id, ps.beneficiary_type, ps.index_ben_reg_id, ps.status + FROM db_iemr.nikshay_beneficiary_phase_status ps + JOIN db_iemr.nikshay_beneficiary_phase_status enrolment + ON enrolment.ben_reg_id = ps.ben_reg_id + AND enrolment.phase = 'ENROLMENT' + AND enrolment.status = 'DONE' + WHERE ps.phase = 'CONTACTS' + AND (ps.status IN ('PENDING', 'IN_PROGRESS') + OR (ps.status = 'FAILED' + AND (ps.failure_reason IN ('MANDATORY_FIELD_MISSING', 'TEST_RESULT_INVALID') + OR (ps.failure_reason IN ('NIKSHAY_UNAVAILABLE', 'LOGIN_REJECTED') + AND ps.attempts < :maxAttempts)))) + ORDER BY ps.updated_at, ps.id + LIMIT :limit + """; + + // XRAY_UPLOAD: beneficiaries not transferred out yet (once TRANSFER_OUT is DONE their tests + // are no longer uploaded) with a completed chest X-ray result (XRAY_CHEST order with a + // result entered) who are either + // 1. waiting in the phase, or + // 2. DONE, with a newer valid result (not an error / invalid run) not uploaded yet, i.e. + // no SUCCESS entry for the order in the job history log. + // The log table (V132) and tb_diagnostic_order have different collations, so the order + // IDs are compared in one explicit collation. + private static final String WAITING_FOR_XRAY_UPLOAD_SQL = """ + SELECT ps.ben_reg_id, ps.beneficiary_id, ps.beneficiary_type, ps.index_ben_reg_id, ps.status + FROM db_iemr.nikshay_beneficiary_phase_status ps + JOIN db_iemr.nikshay_beneficiary_phase_status enrolment + ON enrolment.ben_reg_id = ps.ben_reg_id + AND enrolment.phase = 'ENROLMENT' + AND enrolment.status = 'DONE' + WHERE ps.phase = 'XRAY_UPLOAD' + AND ( + ((ps.status IN ('PENDING', 'IN_PROGRESS') + OR (ps.status = 'FAILED' + AND (ps.failure_reason IN ('MANDATORY_FIELD_MISSING', 'TEST_RESULT_INVALID') + OR (ps.failure_reason IN ('NIKSHAY_UNAVAILABLE', 'LOGIN_REJECTED') + AND ps.attempts < :maxAttempts)))) + AND EXISTS (SELECT 1 + FROM db_iemr.tb_diagnostic_order o + JOIN db_iemr.tb_diagnostic_result r + ON r.external_order_id = o.external_order_id AND r.deleted = 0 + WHERE o.beneficiary_id = ps.beneficiary_id + AND o.order_type = 'XRAY_CHEST' + AND o.status = 'COMPLETED' AND o.deleted = 0 + AND NULLIF(TRIM(r.result_summary), '') IS NOT NULL)) + OR + (ps.status = 'DONE' + AND EXISTS (SELECT 1 + FROM db_iemr.tb_diagnostic_order o + JOIN db_iemr.tb_diagnostic_result r + ON r.external_order_id = o.external_order_id AND r.deleted = 0 + WHERE o.beneficiary_id = ps.beneficiary_id + AND o.order_type = 'XRAY_CHEST' + AND o.status = 'COMPLETED' AND o.deleted = 0 + AND NULLIF(TRIM(r.result_summary), '') IS NOT NULL + AND LOWER(TRIM(r.result_summary)) NOT LIKE 'error%' + AND LOWER(r.result_summary) NOT LIKE '%invalid%' + AND NOT EXISTS (SELECT 1 + FROM db_iemr.nikshay_worker_job_history_log j + WHERE j.reference_id COLLATE utf8mb4_unicode_ci + = o.external_order_id COLLATE utf8mb4_unicode_ci + AND j.status = 'SUCCESS'))) + ) + AND NOT EXISTS (SELECT 1 + FROM db_iemr.nikshay_beneficiary_phase_status transfer + WHERE transfer.ben_reg_id = ps.ben_reg_id + AND transfer.phase = 'TRANSFER_OUT' + AND transfer.status = 'DONE') + ORDER BY ps.updated_at, ps.id + LIMIT :limit + """; + + // NAAT_UPLOAD: the same as XRAY_UPLOAD for TrueNat results (MTB and MDR_RIF orders), and + // only once the chest X-ray is uploaded: the beneficiary's XRAY_UPLOAD is DONE or + // NOT_APPLICABLE, or they have no X-ray order at all. + private static final String WAITING_FOR_NAAT_UPLOAD_SQL = """ + SELECT ps.ben_reg_id, ps.beneficiary_id, ps.beneficiary_type, ps.index_ben_reg_id, ps.status + FROM db_iemr.nikshay_beneficiary_phase_status ps + JOIN db_iemr.nikshay_beneficiary_phase_status enrolment + ON enrolment.ben_reg_id = ps.ben_reg_id + AND enrolment.phase = 'ENROLMENT' + AND enrolment.status = 'DONE' + WHERE ps.phase = 'NAAT_UPLOAD' + AND ( + ((ps.status IN ('PENDING', 'IN_PROGRESS') + OR (ps.status = 'FAILED' + AND (ps.failure_reason IN ('MANDATORY_FIELD_MISSING', 'TEST_RESULT_INVALID') + OR (ps.failure_reason IN ('NIKSHAY_UNAVAILABLE', 'LOGIN_REJECTED') + AND ps.attempts < :maxAttempts)))) + AND EXISTS (SELECT 1 + FROM db_iemr.tb_diagnostic_order o + JOIN db_iemr.tb_diagnostic_result r + ON r.external_order_id = o.external_order_id AND r.deleted = 0 + WHERE o.beneficiary_id = ps.beneficiary_id + AND o.order_type IN ('MTB', 'MDR_RIF') + AND o.status = 'COMPLETED' AND o.deleted = 0 + AND NULLIF(TRIM(r.result_summary), '') IS NOT NULL)) + OR + (ps.status = 'DONE' + AND EXISTS (SELECT 1 + FROM db_iemr.tb_diagnostic_order o + JOIN db_iemr.tb_diagnostic_result r + ON r.external_order_id = o.external_order_id AND r.deleted = 0 + WHERE o.beneficiary_id = ps.beneficiary_id + AND o.order_type IN ('MTB', 'MDR_RIF') + AND o.status = 'COMPLETED' AND o.deleted = 0 + AND NULLIF(TRIM(r.result_summary), '') IS NOT NULL + AND LOWER(TRIM(r.result_summary)) NOT LIKE 'error%' + AND LOWER(r.result_summary) NOT LIKE '%invalid%' + AND NOT EXISTS (SELECT 1 + FROM db_iemr.nikshay_worker_job_history_log j + WHERE j.reference_id COLLATE utf8mb4_unicode_ci + = o.external_order_id COLLATE utf8mb4_unicode_ci + AND j.status = 'SUCCESS'))) + ) + AND (EXISTS (SELECT 1 + FROM db_iemr.nikshay_beneficiary_phase_status xray + WHERE xray.ben_reg_id = ps.ben_reg_id + AND xray.phase = 'XRAY_UPLOAD' + AND xray.status IN ('DONE', 'NOT_APPLICABLE')) + OR NOT EXISTS (SELECT 1 + FROM db_iemr.tb_diagnostic_order xo + WHERE xo.beneficiary_id = ps.beneficiary_id + AND xo.order_type = 'XRAY_CHEST' + AND xo.deleted = 0)) + AND NOT EXISTS (SELECT 1 + FROM db_iemr.nikshay_beneficiary_phase_status transfer + WHERE transfer.ben_reg_id = ps.ben_reg_id + AND transfer.phase = 'TRANSFER_OUT' + AND transfer.status = 'DONE') + ORDER BY ps.updated_at, ps.id + LIMIT :limit + """; + + // TRANSFER_OUT: beneficiaries whose X-ray and NAAT uploads are both DONE or NOT_APPLICABLE. + // Besides the usual waiting rule, a row FAILED with DESIGNATED_PHI_NOT_MAPPED is tried + // again every run, so the transfer goes once the village's designated PHI is added. + // Who is actually transferred is decided by the transfer out run. + private static final String WAITING_FOR_TRANSFER_OUT_SQL = """ + SELECT ps.ben_reg_id, ps.beneficiary_id, ps.beneficiary_type, ps.index_ben_reg_id, ps.status + FROM db_iemr.nikshay_beneficiary_phase_status ps + JOIN db_iemr.nikshay_beneficiary_phase_status enrolment + ON enrolment.ben_reg_id = ps.ben_reg_id + AND enrolment.phase = 'ENROLMENT' + AND enrolment.status = 'DONE' + JOIN db_iemr.nikshay_beneficiary_phase_status xray + ON xray.ben_reg_id = ps.ben_reg_id + AND xray.phase = 'XRAY_UPLOAD' + AND xray.status IN ('DONE', 'NOT_APPLICABLE') + JOIN db_iemr.nikshay_beneficiary_phase_status naat + ON naat.ben_reg_id = ps.ben_reg_id + AND naat.phase = 'NAAT_UPLOAD' + AND naat.status IN ('DONE', 'NOT_APPLICABLE') + WHERE ps.phase = 'TRANSFER_OUT' + AND (ps.status IN ('PENDING', 'IN_PROGRESS') + OR (ps.status = 'FAILED' + AND (ps.failure_reason IN ('MANDATORY_FIELD_MISSING', 'TEST_RESULT_INVALID', + 'DESIGNATED_PHI_NOT_MAPPED') + OR (ps.failure_reason IN ('NIKSHAY_UNAVAILABLE', 'LOGIN_REJECTED') + AND ps.attempts < :maxAttempts)))) + ORDER BY ps.updated_at, ps.id + LIMIT :limit + """; + + // The given beneficiaries whose NAAT must still wait for the chest X-ray: they have an + // X-ray order, and their XRAY_UPLOAD is not DONE or NOT_APPLICABLE (still to come or + // failed). Read from their ENROLMENT rows. + private static final String WAITING_FOR_XRAY_SQL = """ + SELECT ps.ben_reg_id + FROM db_iemr.nikshay_beneficiary_phase_status ps + WHERE ps.phase = 'ENROLMENT' + AND ps.ben_reg_id IN (:benRegIds) + AND NOT EXISTS (SELECT 1 + FROM db_iemr.nikshay_beneficiary_phase_status xray + WHERE xray.ben_reg_id = ps.ben_reg_id + AND xray.phase = 'XRAY_UPLOAD' + AND xray.status IN ('DONE', 'NOT_APPLICABLE')) + AND EXISTS (SELECT 1 + FROM db_iemr.tb_diagnostic_order xo + WHERE xo.beneficiary_id = ps.beneficiary_id + AND xo.order_type = 'XRAY_CHEST' + AND xo.deleted = 0) + """; + + // The given beneficiaries whose row for the phase is FAILED with an error that is not + // retried automatically (the opposite of the waiting rule above): they wait for a reprocess. + private static final String WAITING_FOR_REPROCESS_SQL = """ + SELECT ps.ben_reg_id + FROM db_iemr.nikshay_beneficiary_phase_status ps + WHERE ps.phase = :phase + AND ps.ben_reg_id IN (:benRegIds) + AND ps.status = 'FAILED' + AND NOT (ps.failure_reason IN ('MANDATORY_FIELD_MISSING', 'TEST_RESULT_INVALID') + OR (ps.failure_reason IN ('NIKSHAY_UNAVAILABLE', 'LOGIN_REJECTED') + AND ps.attempts < :maxAttempts)) + """; + + // Whether the beneficiary (BeneficiaryID) has a chest X-ray order, in any status. + private static final String HAS_XRAY_ORDER_SQL = """ + SELECT COUNT(*) + FROM db_iemr.tb_diagnostic_order o + WHERE o.beneficiary_id = :beneficiaryId + AND o.order_type = 'XRAY_CHEST' + AND o.deleted = 0 + """; + + // Whether the beneficiary (BeneficiaryID) has a TrueNat order (MTB or MDR_RIF), in any status. + private static final String HAS_NAAT_ORDER_SQL = """ + SELECT COUNT(*) + FROM db_iemr.tb_diagnostic_order o + WHERE o.beneficiary_id = :beneficiaryId + AND o.order_type IN ('MTB', 'MDR_RIF') + AND o.deleted = 0 + """; + + // The given beneficiaries' rows for the phase, whatever their status. + private static final String ROWS_SQL = """ + SELECT ps.ben_reg_id, ps.beneficiary_id, ps.beneficiary_type, ps.index_ben_reg_id, ps.status + FROM db_iemr.nikshay_beneficiary_phase_status ps + WHERE ps.phase = :phase + AND ps.ben_reg_id IN (:benRegIds) + """; + + private static final String RESET_TO_PENDING_SQL = "UPDATE db_iemr.nikshay_beneficiary_phase_status " + + "SET status = 'PENDING', failure_reason = NULL, error_detail = :note, attempts = 0, completed_at = NULL " + + "WHERE phase = :phase AND ben_reg_id IN (:benRegIds)"; + + @Autowired + private DataSource dataSource; + + /** + * Saves the beneficiary's status for the phase (insert or update). countAttempt says + * whether this is a try of the phase (IN_PROGRESS, DONE, FAILED) or not (PENDING, + * NOT_APPLICABLE). + */ + public void save(Long benRegId, String beneficiaryType, Long indexBenRegId, String phase, String status, + String failureReason, String errorDetail, boolean countAttempt, String nikshayId, String episodeId, + Timestamp lastAttemptAt, Timestamp completedAt) { + MapSqlParameterSource params = new MapSqlParameterSource(); + params.addValue("benRegId", benRegId); + params.addValue("beneficiaryType", beneficiaryType); + params.addValue("indexBenRegId", indexBenRegId); + params.addValue("phase", phase); + params.addValue("status", status); + params.addValue("failureReason", failureReason); + params.addValue("errorDetail", errorDetail); + params.addValue("countAttempt", countAttempt ? 1 : 0); + params.addValue("nikshayId", nikshayId); + params.addValue("episodeId", episodeId); + params.addValue("lastAttemptAt", lastAttemptAt); + params.addValue("completedAt", completedAt); + new NamedParameterJdbcTemplate(dataSource).update(SAVE_SQL, params); + } + + /** + * One page of beneficiaries matching the filters, newest change first. Every filter is + * optional: phase / status (status NOT_STARTED = no row for the phase), failedOnly + * (failed in the phase, or in any phase), failureReason, beneficiaryType, and search + * (BenRegId, or part of the name). Each row: ben_reg_id, beneficiary_id, + * beneficiary_type, index_ben_reg_id, name, index_name. + */ + public List> findBeneficiaries(String phase, String status, boolean failedOnly, + String failureReason, String beneficiaryType, String search, int page, int size) { + MapSqlParameterSource params = new MapSqlParameterSource(); + String sql = "SELECT b.ben_reg_id, b.beneficiary_id, b.beneficiary_type, b.index_ben_reg_id, " + + name("b") + " AS name, " + name("i") + " AS index_name " + + filteredBeneficiariesSql(phase, status, failedOnly, failureReason, beneficiaryType, search, params) + + "ORDER BY b.updated_at DESC, b.ben_reg_id DESC LIMIT :size OFFSET :offset"; + params.addValue("size", size); + params.addValue("offset", page * size); + return new NamedParameterJdbcTemplate(dataSource).queryForList(sql, params); + } + + /** How many beneficiaries match the same filters as findBeneficiaries. */ + public long countBeneficiaries(String phase, String status, boolean failedOnly, String failureReason, + String beneficiaryType, String search) { + MapSqlParameterSource params = new MapSqlParameterSource(); + String sql = "SELECT COUNT(*) " + + filteredBeneficiariesSql(phase, status, failedOnly, failureReason, beneficiaryType, search, params); + Long count = new NamedParameterJdbcTemplate(dataSource).queryForObject(sql, params, Long.class); + return count == null ? 0 : count; + } + + // FROM / WHERE part shared by findBeneficiaries and countBeneficiaries + private static String filteredBeneficiariesSql(String phase, String status, boolean failedOnly, + String failureReason, String beneficiaryType, String search, MapSqlParameterSource params) { + StringBuilder having = new StringBuilder("HAVING 1 = 1 "); + if (phase != null) { + params.addValue("phase", phase); + } + if (status != null && status.equals("NOT_STARTED")) { + if (phase != null) { + having.append("AND SUM(phase = :phase) = 0 "); + } + } else if (status != null) { + params.addValue("status", status); + having.append(phase != null ? "AND SUM(phase = :phase AND status = :status) > 0 " + : "AND SUM(status = :status) > 0 "); + } else if (phase != null) { + having.append("AND SUM(phase = :phase) > 0 "); + } + if (failedOnly) { + having.append(phase != null ? "AND SUM(phase = :phase AND status = 'FAILED') > 0 " + : "AND SUM(status = 'FAILED') > 0 "); + } + if (failureReason != null) { + params.addValue("failureReason", failureReason); + having.append("AND SUM(status = 'FAILED' AND failure_reason = :failureReason) > 0 "); + } + if (beneficiaryType != null) { + params.addValue("beneficiaryType", beneficiaryType); + having.append("AND MAX(beneficiary_type) = :beneficiaryType "); + } + String where = "WHERE 1 = 1 "; + if (search != null) { + if (search.matches("\\d+")) { + params.addValue("searchId", Long.valueOf(search)); + where += "AND b.ben_reg_id = :searchId "; + } else { + params.addValue("searchName", "%" + search.toLowerCase() + "%"); + where += "AND LOWER(" + name("b") + ") LIKE :searchName "; + } + } + return "FROM ( " + + " SELECT ben_reg_id, MAX(beneficiary_id) AS beneficiary_id, MAX(beneficiary_type) AS beneficiary_type, " + + " MAX(index_ben_reg_id) AS index_ben_reg_id, MAX(updated_at) AS updated_at " + + " FROM db_iemr.nikshay_beneficiary_phase_status GROUP BY ben_reg_id " + having + ") b " + + nameJoins("b.ben_reg_id", "b") + nameJoins("b.index_ben_reg_id", "i") + where; + } + + /** Every phase row of the given beneficiaries. */ + public List> findPhaseRows(List benRegIds) { + return new NamedParameterJdbcTemplate(dataSource).queryForList(PHASE_ROWS_SQL, + new MapSqlParameterSource("benRegIds", benRegIds)); + } + + /** Up to limit index patients waiting in CONTACTS (see WAITING_FOR_CONTACTS_SQL). */ + public List findWaitingForContacts(int maxAttempts, int limit) { + return findWaiting(WAITING_FOR_CONTACTS_SQL, maxAttempts, limit); + } + + /** Up to limit beneficiaries waiting in XRAY_UPLOAD (see WAITING_FOR_XRAY_UPLOAD_SQL). */ + public List findWaitingForXrayUpload(int maxAttempts, int limit) { + return findWaiting(WAITING_FOR_XRAY_UPLOAD_SQL, maxAttempts, limit); + } + + /** Up to limit beneficiaries waiting in NAAT_UPLOAD (see WAITING_FOR_NAAT_UPLOAD_SQL). */ + public List findWaitingForNaatUpload(int maxAttempts, int limit) { + return findWaiting(WAITING_FOR_NAAT_UPLOAD_SQL, maxAttempts, limit); + } + + /** Up to limit beneficiaries waiting in TRANSFER_OUT (see WAITING_FOR_TRANSFER_OUT_SQL). */ + public List findWaitingForTransferOut(int maxAttempts, int limit) { + return findWaiting(WAITING_FOR_TRANSFER_OUT_SQL, maxAttempts, limit); + } + + /** Whether the beneficiary (BeneficiaryID) has a chest X-ray order (see HAS_XRAY_ORDER_SQL). */ + public boolean hasXrayOrder(Long beneficiaryId) { + Integer count = new NamedParameterJdbcTemplate(dataSource).queryForObject(HAS_XRAY_ORDER_SQL, + new MapSqlParameterSource("beneficiaryId", beneficiaryId), Integer.class); + return count != null && count > 0; + } + + /** Whether the beneficiary (BeneficiaryID) has a TrueNat order (see HAS_NAAT_ORDER_SQL). */ + public boolean hasNaatOrder(Long beneficiaryId) { + Integer count = new NamedParameterJdbcTemplate(dataSource).queryForObject(HAS_NAAT_ORDER_SQL, + new MapSqlParameterSource("beneficiaryId", beneficiaryId), Integer.class); + return count != null && count > 0; + } + + /** The given beneficiaries' rows for the phase; a beneficiary with no row is left out. */ + public List findRows(String phase, List benRegIds) { + MapSqlParameterSource params = new MapSqlParameterSource(); + params.addValue("phase", phase); + params.addValue("benRegIds", benRegIds); + return new NamedParameterJdbcTemplate(dataSource).query(ROWS_SQL, params, (rs, rowNum) -> mapCandidate(rs)); + } + + /** The given beneficiaries whose NAAT waits for the chest X-ray (see WAITING_FOR_XRAY_SQL). */ + public List findWaitingForXray(List benRegIds) { + return new NamedParameterJdbcTemplate(dataSource).queryForList(WAITING_FOR_XRAY_SQL, + new MapSqlParameterSource("benRegIds", benRegIds), Long.class); + } + + /** The given beneficiaries waiting for a reprocess in the phase (see WAITING_FOR_REPROCESS_SQL). */ + public List findWaitingForReprocess(String phase, List benRegIds, int maxAttempts) { + MapSqlParameterSource params = new MapSqlParameterSource(); + params.addValue("phase", phase); + params.addValue("benRegIds", benRegIds); + params.addValue("maxAttempts", maxAttempts); + return new NamedParameterJdbcTemplate(dataSource).queryForList(WAITING_FOR_REPROCESS_SQL, params, Long.class); + } + + private List findWaiting(String sql, int maxAttempts, int limit) { + MapSqlParameterSource params = new MapSqlParameterSource(); + params.addValue("maxAttempts", maxAttempts); + params.addValue("limit", limit); + return new NamedParameterJdbcTemplate(dataSource).query(sql, params, (rs, rowNum) -> mapCandidate(rs)); + } + + /** + * Sets the given beneficiaries' rows for the phase back to PENDING with no attempts + * (reprocess). Returns the number of rows changed; beneficiaries with no row are skipped. + */ + public int resetToPending(String phase, List benRegIds, String note) { + MapSqlParameterSource params = new MapSqlParameterSource(); + params.addValue("phase", phase); + params.addValue("benRegIds", benRegIds); + params.addValue("note", note); + return new NamedParameterJdbcTemplate(dataSource).update(RESET_TO_PENDING_SQL, params); + } + + private NikshayPhaseCandidate mapCandidate(ResultSet rs) throws SQLException { + String type = rs.getString("beneficiary_type"); + return new NikshayPhaseCandidate(rs.getObject("ben_reg_id", Long.class), + rs.getObject("beneficiary_id", Long.class), type == null ? null : NikshayBeneficiaryType.valueOf(type), + rs.getObject("index_ben_reg_id", Long.class), rs.getString("status")); + } + + /** Number of beneficiaries per phase and status. */ + public List countByPhaseAndStatus() { + return new NamedParameterJdbcTemplate(dataSource).query(SUMMARY_SQL, new MapSqlParameterSource(), + (rs, rowNum) -> mapCount(rs)); + } + + private NikshayPhaseStatusCount mapCount(ResultSet rs) throws SQLException { + return new NikshayPhaseStatusCount(rs.getString("phase"), rs.getString("status"), rs.getLong("cnt")); + } +} diff --git a/src/main/java/com/iemr/admin/repo/nikshaytransferout/NikshayTransferOutRepository.java b/src/main/java/com/iemr/admin/repo/nikshaytransferout/NikshayTransferOutRepository.java new file mode 100644 index 00000000..f55dd07d --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshaytransferout/NikshayTransferOutRepository.java @@ -0,0 +1,153 @@ +/* +* 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.admin.repo.nikshaytransferout; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshaytransferout.NikshayContactTestsDTO; +import com.iemr.admin.data.nikshaytransferout.NikshayDesignatedPhiDTO; + +/** Reads what the transfer out phase needs besides the phase status rows. */ +@Repository +public class NikshayTransferOutRepository { + + // Each village's newest active designated PHI, with its name from the facility master. + // The two tables have different collations, so the codes are compared in one explicit + // collation. + private static final String DESIGNATED_PHI_SQL = """ + SELECT p.village_id, p.designated_phi_id, + (SELECT f.FacilityName + FROM db_iemr.m_nikshay_facility f + WHERE f.NikshayCode COLLATE utf8mb4_unicode_ci = p.designated_phi_id COLLATE utf8mb4_unicode_ci + AND (f.Deleted = 0 OR f.Deleted IS NULL) + LIMIT 1) AS phi_name + FROM db_iemr.m_nikshay_transfer_out_phi_village_mapping p + WHERE p.village_id IN (:villageIds) + AND p.status = 'active' + AND p.id = (SELECT MAX(p2.id) + FROM db_iemr.m_nikshay_transfer_out_phi_village_mapping p2 + WHERE p2.village_id = p.village_id + AND p2.status = 'active') + """; + + // For each given beneficiary (by BenRegId): whether any completed TrueNat (MTB) result is + // "TB Positive", their latest completed chest X-ray result, and whether their latest TB + // screening shows a symptom (same symptom check as the enrolment's Symptom field). + private static final String CONTACT_TESTS_SQL = """ + SELECT ps.ben_reg_id, + EXISTS (SELECT 1 + FROM db_iemr.tb_diagnostic_order o + JOIN db_iemr.tb_diagnostic_result r + ON r.external_order_id = o.external_order_id AND r.deleted = 0 + WHERE o.beneficiary_id = ps.beneficiary_id + AND o.order_type = 'MTB' + AND o.status = 'COMPLETED' AND o.deleted = 0 + AND LOWER(TRIM(r.result_summary)) = 'tb positive') AS naat_positive, + (SELECT r.result_summary + FROM db_iemr.tb_diagnostic_order o + JOIN db_iemr.tb_diagnostic_result r + ON r.external_order_id = o.external_order_id AND r.deleted = 0 + WHERE o.beneficiary_id = ps.beneficiary_id + AND o.order_type = 'XRAY_CHEST' + AND o.status = 'COMPLETED' AND o.deleted = 0 + AND NULLIF(TRIM(r.result_summary), '') IS NOT NULL + ORDER BY o.created_date DESC, o.id DESC + LIMIT 1) AS latest_xray, + EXISTS (SELECT 1 + FROM db_iemr.tb_screening scr + WHERE scr.id = (SELECT MAX(s2.id) + FROM db_iemr.tb_screening s2 + WHERE s2.ben_reg_id = ps.ben_reg_id AND s2.deleted = 0) + AND (LOWER(TRIM(IFNULL(scr.cough_check, ''))) IN ('1', 'yes', 'y', 'true') + OR LOWER(TRIM(IFNULL(scr.fever_check, ''))) IN ('1', 'yes', 'y', 'true') + OR LOWER(TRIM(IFNULL(scr.weight_check, ''))) IN ('1', 'yes', 'y', 'true') + OR LOWER(TRIM(IFNULL(scr.sweats_check, ''))) IN ('1', 'yes', 'y', 'true'))) AS symptomatic + FROM db_iemr.nikshay_beneficiary_phase_status ps + WHERE ps.phase = 'ENROLMENT' + AND ps.ben_reg_id IN (:benRegIds) + """; + + // The given beneficiaries already transferred out: a SUCCESS transfer in the job history + // log (so a reprocess never transfers them again). + private static final String TRANSFERRED_SQL = """ + SELECT DISTINCT j.ben_id + FROM db_iemr.nikshay_worker_job_history_log j + WHERE j.phase = 'TRANSFER_OUT' + AND j.status = 'SUCCESS' + AND j.ben_id IN (:benRegIds) + """; + + @Autowired + private DataSource dataSource; + + /** The newest active designated PHI of each given village, by village ID. */ + public Map findDesignatedPhis(List villageIds) { + Map phis = new HashMap<>(); + if (villageIds.isEmpty()) { + return phis; + } + List rows = new NamedParameterJdbcTemplate(dataSource).query(DESIGNATED_PHI_SQL, + new MapSqlParameterSource("villageIds", villageIds), + (rs, rowNum) -> new NikshayDesignatedPhiDTO(rs.getObject("village_id", Integer.class), + rs.getString("designated_phi_id"), rs.getString("phi_name"))); + for (NikshayDesignatedPhiDTO row : rows) { + phis.put(row.getVillageId(), row); + } + return phis; + } + + /** The test results and symptoms of each given beneficiary, by BenRegId. */ + public Map findContactTests(List benRegIds) { + Map tests = new HashMap<>(); + if (benRegIds.isEmpty()) { + return tests; + } + List rows = new NamedParameterJdbcTemplate(dataSource).query(CONTACT_TESTS_SQL, + new MapSqlParameterSource("benRegIds", benRegIds), + (rs, rowNum) -> new NikshayContactTestsDTO(rs.getObject("ben_reg_id", Long.class), + rs.getBoolean("naat_positive"), rs.getString("latest_xray"), rs.getBoolean("symptomatic"))); + for (NikshayContactTestsDTO row : rows) { + tests.put(row.getBenRegId(), row); + } + return tests; + } + + /** The given beneficiaries already transferred out (see TRANSFERRED_SQL). */ + public Set findTransferred(List benRegIds) { + if (benRegIds.isEmpty()) { + return new HashSet<>(); + } + return new HashSet<>(new NamedParameterJdbcTemplate(dataSource).queryForList(TRANSFERRED_SQL, + new MapSqlParameterSource("benRegIds", benRegIds), Long.class)); + } +} diff --git a/src/main/java/com/iemr/admin/repo/nikshayupload/DiagnosticOrderRepo.java b/src/main/java/com/iemr/admin/repo/nikshayupload/DiagnosticOrderRepo.java new file mode 100644 index 00000000..a13b84b1 --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshayupload/DiagnosticOrderRepo.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.admin.repo.nikshayupload; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.DiagnosticOrder; +import com.iemr.admin.data.nikshayupload.NaatResultDTO; +import com.iemr.admin.data.nikshayupload.XrayResultDTO; + +@Repository +public interface DiagnosticOrderRepo extends CrudRepository { + + // Completed MTB orders with their result, joined on externalOrderId (see DiagnosticResult). + // Ni-kshay ID and current village/state come from db_identity: BeneficiaryID -> BenRegId + // (m_beneficiaryregidmapping) -> details/address (i_beneficiarymapping). LEFT JOINs, so a + // beneficiary whose identity hasn't synced still returns its result, with those fields null. + // nv gives the Ni-kshay name for the village ID, so the service can tell whether the ID + // really is a Ni-kshay village ID (see NikshayUploadService.villageProblem). The Ni-kshay + // state code is taken from the village's own hierarchy, not from CurrStateId: that is an + // AMRIT m_state ID, and the same number is a different state in m_nikshay_state. + @Query(" SELECT new com.iemr.admin.data.nikshayupload.NaatResultDTO(o.beneficiaryId, rm.benRegId, d.nikshayId, " + + " a.currVillageId, a.currVillage, nv.villageName, a.currStateId, ns.nikshayCode, " + + " nf.facilityName, nf.nikshayCode, o.visitCode, o.vanID, o.externalOrderId, r.resultSummary, " + + " r.tbPresence, o.createdDate, r.createdDate) " + + " FROM DiagnosticOrder o " + + " JOIN DiagnosticResult r ON r.externalOrderId = o.externalOrderId AND r.deleted = false " + + " LEFT JOIN NikshayBenRegIdMapping rm ON rm.beneficiaryID = o.beneficiaryId AND rm.deleted = false " + + " LEFT JOIN NikshayBenMapping m ON m.benRegId = rm.benRegId AND m.deleted = false " + + " LEFT JOIN NikshayBenDetails d ON d.beneficiaryDetailsId = m.benDetailsId AND d.deleted = false " + + " LEFT JOIN NikshayBenAddress a ON a.benAddressID = m.benAddressId " + + " LEFT JOIN NikshayVillage nv ON nv.nikshayVillageID = a.currVillageId AND nv.deleted = false " + + " LEFT JOIN NikshayFacility nf ON nf.nikshayFacilityID = nv.nikshayFacilityID AND nf.deleted = false " + + " LEFT JOIN NikshayTU nt ON nt.nikshayTUID = nf.nikshayTUID AND nt.deleted = false " + + " LEFT JOIN NikshayDistrict nd ON nd.nikshayDistrictID = nt.nikshayDistrictID AND nd.deleted = false " + + " LEFT JOIN NikshayState ns ON ns.nikshayStateID = nd.nikshayStateID AND ns.deleted = false " + + " WHERE o.orderType = 'MTB' AND o.status = 'COMPLETED' AND o.deleted = false " + + " AND o.beneficiaryId IN :beneficiaryIds " + + " ORDER BY o.beneficiaryId, o.createdDate ") + List findMtbResults(@Param("beneficiaryIds") List beneficiaryIds); + + // Completed TrueNat Rif resistance (MDR_RIF) orders, joined the same way as findMtbResults. + @Query(" SELECT new com.iemr.admin.data.nikshayupload.NaatResultDTO(o.beneficiaryId, rm.benRegId, d.nikshayId, " + + " a.currVillageId, a.currVillage, nv.villageName, a.currStateId, ns.nikshayCode, " + + " nf.facilityName, nf.nikshayCode, o.visitCode, o.vanID, o.externalOrderId, r.resultSummary, " + + " r.tbPresence, o.createdDate, r.createdDate) " + + " FROM DiagnosticOrder o " + + " JOIN DiagnosticResult r ON r.externalOrderId = o.externalOrderId AND r.deleted = false " + + " LEFT JOIN NikshayBenRegIdMapping rm ON rm.beneficiaryID = o.beneficiaryId AND rm.deleted = false " + + " LEFT JOIN NikshayBenMapping m ON m.benRegId = rm.benRegId AND m.deleted = false " + + " LEFT JOIN NikshayBenDetails d ON d.beneficiaryDetailsId = m.benDetailsId AND d.deleted = false " + + " LEFT JOIN NikshayBenAddress a ON a.benAddressID = m.benAddressId " + + " LEFT JOIN NikshayVillage nv ON nv.nikshayVillageID = a.currVillageId AND nv.deleted = false " + + " LEFT JOIN NikshayFacility nf ON nf.nikshayFacilityID = nv.nikshayFacilityID AND nf.deleted = false " + + " LEFT JOIN NikshayTU nt ON nt.nikshayTUID = nf.nikshayTUID AND nt.deleted = false " + + " LEFT JOIN NikshayDistrict nd ON nd.nikshayDistrictID = nt.nikshayDistrictID AND nd.deleted = false " + + " LEFT JOIN NikshayState ns ON ns.nikshayStateID = nd.nikshayStateID AND ns.deleted = false " + + " WHERE o.orderType = 'MDR_RIF' AND o.status = 'COMPLETED' AND o.deleted = false " + + " AND o.beneficiaryId IN :beneficiaryIds " + + " ORDER BY o.beneficiaryId, o.createdDate ") + List findRifResults(@Param("beneficiaryIds") List beneficiaryIds); + + // Completed chest X-ray orders, joined the same way as findMtbResults. An invalid X-ray + // ("AI Invalid Result") is CLOSED by FLW-API, never COMPLETED, so it's excluded here. + @Query(" SELECT new com.iemr.admin.data.nikshayupload.XrayResultDTO(o.beneficiaryId, rm.benRegId, d.nikshayId, " + + " a.currVillageId, a.currVillage, nv.villageName, a.currStateId, ns.nikshayCode, " + + " nf.facilityName, nf.nikshayCode, o.visitCode, o.vanID, o.externalOrderId, r.resultSummary, " + + " r.tbPresence, o.createdDate, r.createdDate) " + + " FROM DiagnosticOrder o " + + " JOIN DiagnosticResult r ON r.externalOrderId = o.externalOrderId AND r.deleted = false " + + " LEFT JOIN NikshayBenRegIdMapping rm ON rm.beneficiaryID = o.beneficiaryId AND rm.deleted = false " + + " LEFT JOIN NikshayBenMapping m ON m.benRegId = rm.benRegId AND m.deleted = false " + + " LEFT JOIN NikshayBenDetails d ON d.beneficiaryDetailsId = m.benDetailsId AND d.deleted = false " + + " LEFT JOIN NikshayBenAddress a ON a.benAddressID = m.benAddressId " + + " LEFT JOIN NikshayVillage nv ON nv.nikshayVillageID = a.currVillageId AND nv.deleted = false " + + " LEFT JOIN NikshayFacility nf ON nf.nikshayFacilityID = nv.nikshayFacilityID AND nf.deleted = false " + + " LEFT JOIN NikshayTU nt ON nt.nikshayTUID = nf.nikshayTUID AND nt.deleted = false " + + " LEFT JOIN NikshayDistrict nd ON nd.nikshayDistrictID = nt.nikshayDistrictID AND nd.deleted = false " + + " LEFT JOIN NikshayState ns ON ns.nikshayStateID = nd.nikshayStateID AND ns.deleted = false " + + " WHERE o.orderType = 'XRAY_CHEST' AND o.status = 'COMPLETED' AND o.deleted = false " + + " AND o.beneficiaryId IN :beneficiaryIds " + + " ORDER BY o.beneficiaryId, o.createdDate ") + List findXrayResults(@Param("beneficiaryIds") List beneficiaryIds); +} diff --git a/src/main/java/com/iemr/admin/repo/nikshayupload/NikshayDeviceCredentialRepo.java b/src/main/java/com/iemr/admin/repo/nikshayupload/NikshayDeviceCredentialRepo.java new file mode 100644 index 00000000..a32ef7b1 --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshayupload/NikshayDeviceCredentialRepo.java @@ -0,0 +1,55 @@ +/* +* 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.admin.repo.nikshayupload; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.NikshayDeviceName; +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; +import com.iemr.admin.data.nikshay.NikshayDeviceVillageMapping; + +@Repository +public interface NikshayDeviceCredentialRepo extends CrudRepository { + + // Active device logins of the given type (NAAT / XRAY) for the given villages — only + // active mappings of active devices, the same rule findPhiByLocation uses. + // The mapping's facility is the device's PHI, sent to Ni-kshay as the testing facility, with + // its district and state (through its TU) as the testing facility's district and state. + @Query(" SELECT new com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO(m.villageId, d.id, d.deviceSerialNumber, " + + " d.deviceName, d.phiUserId, d.password, f.nikshayCode, f.facilityName, nd.nikshayCode, nd.districtName, " + + " ns.nikshayCode, ns.stateName) " + + " FROM NikshayDeviceVillageMapping m " + + " JOIN NikshayDeviceConfiguration d ON d.id = m.deviceDetailId " + + " LEFT JOIN NikshayFacility f ON f.nikshayFacilityID = m.facilityId AND f.deleted = false " + + " LEFT JOIN NikshayTU nt ON nt.nikshayTUID = f.nikshayTUID AND nt.deleted = false " + + " LEFT JOIN NikshayDistrict nd ON nd.nikshayDistrictID = nt.nikshayDistrictID AND nd.deleted = false " + + " LEFT JOIN NikshayState ns ON ns.nikshayStateID = nd.nikshayStateID AND ns.deleted = false " + + " WHERE m.isActive = true AND d.isActive = true " + + " AND d.deviceName = :deviceName AND m.villageId IN :villageIds ") + List findActiveCredentials(@Param("villageIds") List villageIds, + @Param("deviceName") NikshayDeviceName deviceName); +} diff --git a/src/main/java/com/iemr/admin/service/nikshayclient/NikshayApiException.java b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayApiException.java new file mode 100644 index 00000000..bfdc034d --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayApiException.java @@ -0,0 +1,61 @@ +/* +* 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.admin.service.nikshayclient; + +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +/** A Ni-kshay API call that failed (test upload, village lookup, duplicate check), with the history log reason. */ +public class NikshayApiException extends Exception { + + private static final long serialVersionUID = 1L; + + private final NikshayFailureReason reason; + + // What was sent to Ni-kshay and its full answer, when the call got that far (saved in the + // job history log to debug rejections); null otherwise + private String requestPayload; + private String responseBody; + + public NikshayApiException(NikshayFailureReason reason, String message) { + super(message); + this.reason = reason; + } + + public NikshayFailureReason getReason() { + return reason; + } + + /** Attaches the request sent and Ni-kshay's answer (either may be null); returns this exception. */ + public NikshayApiException withExchange(String requestPayload, String responseBody) { + this.requestPayload = requestPayload; + this.responseBody = responseBody; + return this; + } + + public String getRequestPayload() { + return requestPayload; + } + + public String getResponseBody() { + return responseBody; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayclient/NikshayAuthClient.java b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayAuthClient.java new file mode 100644 index 00000000..d916313d --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayAuthClient.java @@ -0,0 +1,199 @@ +/* +* 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.admin.service.nikshayclient; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +/** + * Logs in to the Ni-kshay portal with a PHI login and returns a NikshaySession + * that Java uses for the portal API calls. + * + * The portal refuses its login form from a plain HTTP client (HTTP 403), so the + * login itself runs in a real browser: the nikshay-enroll Node/Playwright worker + * (login-json.js) logs in and prints the portal's session cookies, and everything + * after that is plain Java HTTP. Needs Node, `npm install` in nikshay-enroll and + * Chrome (or Playwright's Chromium) on the Admin-API host. + * + * The password reaches the worker through its environment, never the command + * line; the password and the cookies are never logged. + */ +@Service +public class NikshayAuthClient { + private static final Logger logger = LoggerFactory.getLogger(NikshayAuthClient.class); + + static final String USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(30); + + // Folder of the nikshay-enroll login worker and the Node binary that runs it + @Value("${nikshay.enroll.dir:nikshay-enroll}") + private String enrollDir; + + @Value("${nikshay.enroll.node:node}") + private String nodeBinary; + + // Portal the worker logs in to: uat (beta.nikshay.in) or production (www.nikshay.in) + @Value("${nikshay.portal.env:uat}") + private String portalEnv; + + @Value("${nikshay.login.timeout-ms:180000}") + private long timeoutMs; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Logs in and returns the session. Throws NikshayLoginException with + * LOGIN_REJECTED when the portal refuses the login, NIKSHAY_UNAVAILABLE when it + * can't be reached, the worker can't run or it answers unexpectedly. + */ + public NikshaySession login(String username, String password) throws NikshayLoginException { + if (username == null || username.isBlank() || password == null || password.isEmpty()) { + throw new NikshayLoginException(NikshayFailureReason.LOGIN_REJECTED, "Username and password are required"); + } + JsonNode result = runLoginWorker(username, password); + if (result.hasNonNull("error")) { + boolean rejected = result.path("rejected").asBoolean(false); + throw new NikshayLoginException( + rejected ? NikshayFailureReason.LOGIN_REJECTED : NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay login failed for " + username + ": " + result.get("error").asText()); + } + String origin = result.path("origin").asText(""); + String cookieHeader = cookieHeader(result.path("cookies")); + if (origin.isEmpty() || cookieHeader.isEmpty()) { + throw new NikshayLoginException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay login for " + username + " returned no session"); + } + HttpClient client = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + logger.info("Ni-kshay login succeeded for {} at {}", username, origin); + NikshaySession session = new NikshaySession(client, origin, username, cookieHeader); + return session; + } + + /** Runs login-json.js and returns its stdout JSON. */ + private JsonNode runLoginWorker(String username, String password) throws NikshayLoginException { + Path workDir = Path.of(enrollDir).toAbsolutePath().normalize(); + Path script = workDir.resolve("login-json.js"); + if (!Files.isRegularFile(script)) { + throw new NikshayLoginException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, "Ni-kshay login worker not found at " + + script + "; check nikshay.enroll.dir and run npm install there"); + } + ProcessBuilder pb = new ProcessBuilder(List.of(nodeBinary, script.toString(), "--env", portalEnv)); + pb.directory(workDir.toFile()); + pb.environment().put("NIKSHAY_USER", username); + pb.environment().put("NIKSHAY_PASS", password); + + logger.info("Ni-kshay login for {} ({}) via {}", username, portalEnv, script); + Process process; + try { + process = pb.start(); + } catch (IOException e) { + throw new NikshayLoginException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Could not start the Ni-kshay login worker (" + nodeBinary + "): " + e.getMessage(), e); + } + // Read both streams while the worker runs, so the timeout below really applies. + CompletableFuture stdout = CompletableFuture.supplyAsync(() -> readAll(process.getInputStream())); + CompletableFuture.runAsync(() -> logLines(process.getErrorStream())); + try { + if (!process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + throw new NikshayLoginException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay login timed out after " + timeoutMs + " ms"); + } + String output = stdout.get(5, TimeUnit.SECONDS); + if (output.isBlank()) { + throw new NikshayLoginException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay login worker produced no output (exit " + process.exitValue() + ")"); + } + return objectMapper.readTree(output); + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + throw new NikshayLoginException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, "Ni-kshay login was interrupted", + e); + } catch (IOException | ExecutionException | TimeoutException e) { + throw new NikshayLoginException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Could not read the Ni-kshay login worker's answer", e); + } + } + + // "name=value; name2=value2" from the worker's cookie list, as a browser sends it + private static String cookieHeader(JsonNode cookies) { + StringBuilder header = new StringBuilder(); + for (JsonNode cookie : cookies) { + String name = cookie.path("name").asText(""); + if (name.isEmpty()) { + continue; + } + if (header.length() > 0) { + header.append("; "); + } + header.append(name).append('=').append(cookie.path("value").asText("")); + } + return header.toString(); + } + + private static String readAll(InputStream in) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")); + } catch (IOException e) { + return ""; + } + } + + // The worker's progress lines (stderr); they never contain the password or cookies. + private static void logLines(InputStream in) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + logger.info("[nikshay-login] {}", line); + } + } catch (IOException e) { + logger.debug("Error reading the Ni-kshay login worker's log", e); + } + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayclient/NikshayDeviceCredentialService.java b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayDeviceCredentialService.java new file mode 100644 index 00000000..f33197d4 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayDeviceCredentialService.java @@ -0,0 +1,90 @@ +/* +* 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.admin.service.nikshayclient; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.iemr.admin.data.nikshay.NikshayDeviceName; +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; +import com.iemr.admin.repo.nikshayupload.NikshayDeviceCredentialRepo; +import com.iemr.admin.utils.crypto.NikshayPasswordCipher; + +/** + * Finds the device PHI login mapped to each village (the device + * configuration), with the password decrypted. Used by every Ni-kshay phase. + */ +@Service +public class NikshayDeviceCredentialService { + private static final Logger logger = LoggerFactory.getLogger(NikshayDeviceCredentialService.class); + + @Autowired + private NikshayDeviceCredentialRepo deviceCredentialRepo; + + @Autowired + private NikshayPasswordCipher passwordCipher; + + /** + * Logins of the given device type (NAAT / XRAY) for the given villages, keyed by + * village ID, password decrypted. Villages with no active device of that type, or + * whose password can't be decrypted, are absent from the map; the latter are also + * added to undecryptableVillages. + */ + public Map findCredentials(Collection villageIds, String deviceType, + Set undecryptableVillages) { + List distinctVillageIds = villageIds.stream().filter(Objects::nonNull).distinct() + .collect(Collectors.toList()); + Map credentialsByVillage = new HashMap<>(); + if (distinctVillageIds.isEmpty()) { + return credentialsByVillage; + } + for (NikshayDeviceCredentialDTO credential : deviceCredentialRepo.findActiveCredentials(distinctVillageIds, + NikshayDeviceName.from(deviceType))) { + try { + credential.setPassword(passwordCipher.decrypt(credential.getPassword())); + } catch (IllegalStateException e) { + logger.error("Skipping village {}: {} for device {}", credential.getVillageId(), e.getMessage(), + credential.getDeviceSerialNumber()); + undecryptableVillages.add(credential.getVillageId()); + continue; + } + credentialsByVillage.put(credential.getVillageId(), credential); + } + List missing = distinctVillageIds.stream() + .filter(id -> !credentialsByVillage.containsKey(id) && !undecryptableVillages.contains(id)) + .collect(Collectors.toList()); + if (!missing.isEmpty()) { + logger.warn("No active {} device mapped for village(s) {}", deviceType, missing); + } + return credentialsByVillage; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayclient/NikshayDiagnosticsTestClient.java b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayDiagnosticsTestClient.java new file mode 100644 index 00000000..f5b77fe7 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayDiagnosticsTestClient.java @@ -0,0 +1,130 @@ +/* +* 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.admin.service.nikshayclient; + +import java.io.IOException; +import java.net.http.HttpResponse; + +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +/** + * Calls Ni-kshay's "add diagnostics test" API (used for both Chest X-ray and + * TrueNat results) with a logged-in session and reads its answer. Only the HTTP + * call and the response: payloads are built by the test type's payload builder. + */ +@Service +public class NikshayDiagnosticsTestClient { + + private static final String ADD_DIAGNOSTICS_TEST_PATH = "/API/TestResults/AddDiagnosticsTest"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** IDs Ni-kshay gives the added test. */ + public static class AddedTest { + private final long requestId; + private final long resultId; + + AddedTest(long requestId, long resultId) { + this.requestId = requestId; + this.resultId = resultId; + } + + public long getRequestId() { + return requestId; + } + + public long getResultId() { + return resultId; + } + } + + /** + * Adds the test. Throws NikshayApiException: NIKSHAY_VALIDATION_ERROR when + * Ni-kshay rejects it (Success false, with its message), NIKSHAY_UNAVAILABLE when it + * can't be reached or no longer accepts the login session. + */ + public AddedTest addDiagnosticsTest(NikshaySession session, ObjectNode payload) throws NikshayApiException { + String request = payload.toString(); + HttpResponse response; + try { + response = session.postJson(ADD_DIAGNOSTICS_TEST_PATH, request); + } catch (IOException e) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Could not reach Ni-kshay: " + e.getMessage()).withExchange(request, null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, "Upload was interrupted") + .withExchange(request, null); + } + try { + return readAddedTest(response); + } catch (NikshayApiException e) { + throw e.withExchange(request, NikshayEnrollClient.exchange(response)); + } + } + + // The added test's IDs, or why Ni-kshay did not give them + private AddedTest readAddedTest(HttpResponse response) throws NikshayApiException { + int status = response.statusCode(); + if (status == 401 || status == 403) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay rejected the login session (HTTP " + status + ")"); + } + if (status >= 500) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay returned HTTP " + status); + } + JsonNode body; + try { + body = objectMapper.readTree(response.body()); + } catch (IOException e) { + // An expired session is redirected to the HTML login page instead of answering JSON + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay answered HTTP " + status + " without JSON; the login session has probably expired"); + } + if (status >= 400 || !body.path("Success").asBoolean(false)) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_VALIDATION_ERROR, + "Ni-kshay rejected the test (HTTP " + status + "): " + errorMessage(body)); + } + JsonNode data = body.path("Data"); + if (!data.hasNonNull("ResultId")) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay reported success but returned no ResultId"); + } + return new AddedTest(data.path("RequestId").asLong(), data.path("ResultId").asLong()); + } + + private static String errorMessage(JsonNode body) { + for (String field : new String[] { "ErrorMessage", "Message", "Error" }) { + if (body.hasNonNull(field) && !body.get(field).asText().isBlank()) { + return body.get(field).asText(); + } + } + String raw = body.toString(); + return raw.length() > 300 ? raw.substring(0, 300) + "…" : raw; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayclient/NikshayEnrollClient.java b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayEnrollClient.java new file mode 100644 index 00000000..cf06e78a --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayEnrollClient.java @@ -0,0 +1,268 @@ +/* +* 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.admin.service.nikshayclient; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +/** + * Ni-kshay calls used to enrol a beneficiary: the village lookup, the duplicate check + * (GetPossibleDuplicates), AddIndiaTB and the household counts (ContactTracing). Only the HTTP calls and reading the + * answers; the decisions are made by NikshayIdGenerateService. + */ +@Service +public class NikshayEnrollClient { + private static final Logger logger = LoggerFactory.getLogger(NikshayEnrollClient.class); + + private static final String POSSIBLE_DUPLICATES_PATH = "/Api/Patients/GetPossibleDuplicates"; + private static final String ADD_INDIA_TB_PATH = "/api/patients/AddIndiaTB"; + private static final String CONTACT_TRACING_PATH = "/Api/Patients/ContactTracing"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * The Ni-kshay ID of the village with this name under the facility (all codes are + * Ni-kshay's own IDs), or null when no village has exactly this name. Looks, in order, + * at the facility's associated villages, all its child villages, then all villages of + * its TU and district. Names are compared exactly (ignoring case and extra spaces); no + * partial matches, since those can pick a different village. (From the earlier + * MMU-API ID generation, nikshay-id-generation-stash.) + */ + public Long findVillageId(NikshaySession session, Long facilityCode, Long tuCode, Long districtCode, + String villageName) throws NikshayApiException { + String[] paths = { + "/API/Hierarchy/GetHierarchyChildrenKeyValue?id=" + facilityCode + + "&type=VILLAGE&fromAssociation=true&onlyActiveHierarchies=true", + "/API/Hierarchy/GetHierarchyChildrenKeyValue?id=" + facilityCode + + "&type=VILLAGE&onlyActiveHierarchies=true", + "/API/Hierarchy/GetHierarchyDescendentsAtSpecificLevel?parentId=" + tuCode + "&level=6&types=VILLAGE", + "/API/Hierarchy/GetHierarchyDescendentsAtSpecificLevel?parentId=" + districtCode + + "&level=6&types=VILLAGE" }; + String wanted = normalize(villageName); + for (String path : paths) { + HttpResponse response; + try { + response = session.get(path); + } catch (IOException e) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Could not reach Ni-kshay for the village list: " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "The village list was interrupted"); + } + JsonNode body = readBody(response, "village list"); + JsonNode rows = body.isArray() ? body : body.path("Data"); + for (JsonNode row : rows) { + String name = firstText(row, "Name", "Value", "name", "value"); + String id = firstText(row, "Id", "Key", "id", "key"); + // The list always ends with "Others" (ID 0), which is not a village + if (normalize(name).equals(wanted) && id != null && id.matches("\\d+") && !id.equals("0")) { + return Long.valueOf(id); + } + } + } + return null; + } + + /** + * Records Ni-kshay already has that may be this person (the portal matches mainly on + * phone and gender). Date of birth is dd-MM-yyyy. Empty when there are none. + */ + public List findPossibleDuplicates(NikshaySession session, String firstName, String lastName, + String gender, String phone, Integer age, String dateOfBirth) throws NikshayApiException { + String path = POSSIBLE_DUPLICATES_PATH + "?PrimaryPhone=" + encode(phone) + "&Gender=" + encode(gender) + + "&FirstName=" + encode(firstName) + "&LastName=" + encode(lastName) + "&Age=" + + (age == null ? "" : age) + "&DateOfBirth=" + encode(dateOfBirth); + HttpResponse response; + try { + response = session.get(path); + } catch (IOException e) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Could not reach Ni-kshay for the duplicate check: " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, "The duplicate check was interrupted"); + } + JsonNode body = readBody(response, "duplicate check"); + // No match is answered as HTTP 200 with Success false and Data null + List records = new ArrayList<>(); + for (JsonNode record : body.path("Data")) { + records.add(record); + } + return records; + } + + /** + * Creates the Ni-kshay record (a new person, or a new episode when the payload has a + * parentEpisodeId) and returns its PatientId. Throws NIKSHAY_VALIDATION_ERROR when + * Ni-kshay rejects it, NIKSHAY_UNAVAILABLE when it can't be reached. + */ + public String addIndiaTb(NikshaySession session, ObjectNode payload) throws NikshayApiException { + String request = payload.toString(); + HttpResponse response; + try { + response = session.postJson(ADD_INDIA_TB_PATH, request); + } catch (IOException e) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Could not reach Ni-kshay for AddIndiaTB: " + e.getMessage()).withExchange(request, null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, "AddIndiaTB was interrupted") + .withExchange(request, null); + } + try { + return readPatientId(response); + } catch (NikshayApiException e) { + throw e.withExchange(request, exchange(response)); + } + } + + // AddIndiaTB's PatientId, or why Ni-kshay did not give one + private String readPatientId(HttpResponse response) throws NikshayApiException { + JsonNode body = readBody(response, "AddIndiaTB"); + if (response.statusCode() >= 400 || !body.path("Success").asBoolean(false)) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_VALIDATION_ERROR, + "Ni-kshay rejected the enrolment (HTTP " + response.statusCode() + "): " + errorMessage(body)); + } + if (!body.hasNonNull("PatientId")) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay reported success but returned no PatientId"); + } + return body.get("PatientId").asText(); + } + + /** + * Writes the household contact counts on the index patient's episode (PUT + * ContactTracing). Creates no person. The response is logged. Beta Ni-kshay answers + * HTTP 200 with {"error": false} on success and {"error": ""} on failure, so + * an HTTP error, an "error" other than false, or "Success": false is a failure. + */ + public void updateContactTracing(NikshaySession session, ObjectNode payload) throws NikshayApiException { + String request = payload.toString(); + HttpResponse response; + try { + response = session.putJson(CONTACT_TRACING_PATH, request); + } catch (IOException e) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Could not reach Ni-kshay for ContactTracing: " + e.getMessage()).withExchange(request, null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, "ContactTracing was interrupted") + .withExchange(request, null); + } + logger.info("ContactTracing for index {} answered HTTP {}: {}", payload.path("Id").asText(), + response.statusCode(), response.body()); + try { + checkContactTracing(response); + } catch (NikshayApiException e) { + throw e.withExchange(request, exchange(response)); + } + } + + // Throws when the ContactTracing answer is a failure (see updateContactTracing) + private void checkContactTracing(HttpResponse response) throws NikshayApiException { + String raw = response.body() == null ? "" : response.body().trim(); + if (raw.isEmpty() && response.statusCode() < 300) { + return; + } + JsonNode body = readBody(response, "ContactTracing"); + JsonNode error = body.path("error"); + boolean errorReported = !error.isMissingNode() && !error.isNull() + && !(error.isBoolean() && !error.asBoolean()); + if (response.statusCode() >= 400 || errorReported + || (body.has("Success") && !body.path("Success").asBoolean(false))) { + String message = error.isTextual() ? error.asText() : errorMessage(body); + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_VALIDATION_ERROR, + "Ni-kshay rejected the household counts (HTTP " + response.statusCode() + "): " + message); + } + } + + // Ni-kshay's full answer, as saved in the job history log + static String exchange(HttpResponse response) { + return "HTTP " + response.statusCode() + ": " + (response.body() == null ? "" : response.body()); + } + + private JsonNode readBody(HttpResponse response, String what) throws NikshayApiException { + int status = response.statusCode(); + if (status == 401 || status == 403) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay rejected the login session during the " + what + " (HTTP " + status + ")"); + } + if (status >= 500) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay " + what + " returned HTTP " + status); + } + try { + return objectMapper.readTree(response.body()); + } catch (IOException e) { + // An expired session is redirected to the HTML login page instead of answering JSON + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay " + what + " answered without JSON; the login session has probably expired"); + } + } + + private static String errorMessage(JsonNode body) { + JsonNode error = body.path("Error"); + if (error.hasNonNull("Message")) { + return error.get("Message").asText(); + } + for (String field : new String[] { "ErrorMessage", "Message", "Error" }) { + if (body.hasNonNull(field) && body.get(field).isTextual() && !body.get(field).asText().isBlank()) { + return body.get(field).asText(); + } + } + String raw = body.toString(); + return raw.length() > 300 ? raw.substring(0, 300) + "…" : raw; + } + + private static String firstText(JsonNode row, String... fields) { + for (String field : fields) { + if (row.hasNonNull(field)) { + return row.get(field).asText().trim(); + } + } + return null; + } + + // Case- and whitespace-insensitive form for comparing names + private static String normalize(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toLowerCase(); + } + + private static String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayclient/NikshayLoginException.java b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayLoginException.java new file mode 100644 index 00000000..6f6f482b --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayLoginException.java @@ -0,0 +1,51 @@ +/* +* 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.admin.service.nikshayclient; + +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +/** + * A Ni-kshay login that didn't succeed. The reason is LOGIN_REJECTED when the + * portal refused the credentials (fix the device login, then reprocess) or + * NIKSHAY_UNAVAILABLE when the portal couldn't be reached or answered wrongly + * (retry later), matching the history log's reasons. + */ +public class NikshayLoginException extends Exception { + + private static final long serialVersionUID = 1L; + + private final NikshayFailureReason reason; + + public NikshayLoginException(NikshayFailureReason reason, String message) { + super(message); + this.reason = reason; + } + + public NikshayLoginException(NikshayFailureReason reason, String message, Throwable cause) { + super(message, cause); + this.reason = reason; + } + + public NikshayFailureReason getReason() { + return reason; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayclient/NikshaySession.java b/src/main/java/com/iemr/admin/service/nikshayclient/NikshaySession.java new file mode 100644 index 00000000..6568ff4f --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayclient/NikshaySession.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.admin.service.nikshayclient; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; + +/** + * A logged-in Ni-kshay portal session for one PHI login. It holds the login cookies + * (from the browser login in NikshayAuthClient) and sends them on every call made + * through get()/postJson()/putJson(). One session is meant for one batch (e.g. one village's + * results) and then dropped. The cookies are a live login: never log or return them. + */ +public class NikshaySession { + + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60); + + private final HttpClient httpClient; + private final String portalUrl; + private final String phiUserId; + // "name=value; name2=value2", as a browser sends it + private final String cookieHeader; + private final Instant loggedInAt = Instant.now(); + + NikshaySession(HttpClient httpClient, String portalUrl, String phiUserId, String cookieHeader) { + this.httpClient = httpClient; + this.portalUrl = portalUrl; + this.phiUserId = phiUserId; + this.cookieHeader = cookieHeader; + } + + /** GET a portal path such as "/Api/Patients/GetPossibleDuplicates?…". */ + public HttpResponse get(String path) throws IOException, InterruptedException { + return httpClient.send(request(path).GET().build(), HttpResponse.BodyHandlers.ofString()); + } + + /** POST a JSON body to a portal path such as "/api/patients/AddIndiaTB". */ + public HttpResponse postJson(String path, String json) throws IOException, InterruptedException { + return httpClient.send(request(path).header("Content-Type", "application/json; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(json)).build(), HttpResponse.BodyHandlers.ofString()); + } + + /** PUT a JSON body to a portal path such as "/Api/Patients/ContactTracing". */ + public HttpResponse putJson(String path, String json) throws IOException, InterruptedException { + return httpClient.send(request(path).header("Content-Type", "application/json; charset=UTF-8") + .PUT(HttpRequest.BodyPublishers.ofString(json)).build(), HttpResponse.BodyHandlers.ofString()); + } + + /** + * POST a JSON body to a full URL on another Ni-kshay host (the registry service for + * transfers), with the portal's cookies, Origin and Referer, as the portal page does. + */ + public HttpResponse postJsonToUrl(String url, String json) throws IOException, InterruptedException { + return httpClient.send(requestTo(url).header("Content-Type", "application/json; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(json)).build(), HttpResponse.BodyHandlers.ofString()); + } + + public String getPortalUrl() { + return portalUrl; + } + + public String getPhiUserId() { + return phiUserId; + } + + public Instant getLoggedInAt() { + return loggedInAt; + } + + // The portal's own pages call these APIs as XHRs from the portal origin. + private HttpRequest.Builder request(String path) { + return requestTo(portalUrl + path); + } + + private HttpRequest.Builder requestTo(String url) { + return HttpRequest.newBuilder(URI.create(url)).timeout(REQUEST_TIMEOUT) + .header("Accept", "*/*") + .header("Origin", portalUrl) + .header("Referer", portalUrl + "/") + .header("X-Requested-With", "XMLHttpRequest") + .header("User-Agent", NikshayAuthClient.USER_AGENT) + .header("Cookie", cookieHeader); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayclient/NikshayTransferClient.java b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayTransferClient.java new file mode 100644 index 00000000..9a311c37 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayclient/NikshayTransferClient.java @@ -0,0 +1,146 @@ +/* +* 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.admin.service.nikshayclient; + +import java.io.IOException; +import java.net.http.HttpResponse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +/** + * Ni-kshay transfer out: POST /v1/transfer on the registry service (registryservice.beta.nikshay.in + * on UAT, registryservice.nikshay.in on production), with the portal login's cookies. Moves + * an episode from one PHI to another. + */ +@Service +public class NikshayTransferClient { + private static final Logger logger = LoggerFactory.getLogger(NikshayTransferClient.class); + + private static final String TRANSFER_PATH = "/v1/transfer"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Sends the transfer and returns Ni-kshay's answer ("HTTP status: body"), which is + * logged since its format is not documented. Throws NikshayApiException with the + * request and answer: TRANSFER_REJECTED when Ni-kshay refuses it, NIKSHAY_UNAVAILABLE + * when it can't be reached or no longer accepts the login session. + */ + public String transfer(NikshaySession session, ObjectNode payload) throws NikshayApiException { + String request = payload.toString(); + String url = registryUrl(session.getPortalUrl()) + TRANSFER_PATH; + HttpResponse response; + try { + response = session.postJsonToUrl(url, request); + } catch (IOException e) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Could not reach Ni-kshay for the transfer: " + e.getMessage()).withExchange(request, null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, "The transfer was interrupted") + .withExchange(request, null); + } + String answer = NikshayEnrollClient.exchange(response); + logger.info("Transfer of episode {} answered {}", payload.path("episodeId").asText(), answer); + try { + checkTransfer(response); + } catch (NikshayApiException e) { + throw e.withExchange(request, answer); + } + return answer; + } + + // Throws when the transfer answer is a failure: login session rejected or expired, a + // server error, an HTTP error, or a JSON body reporting an error + private void checkTransfer(HttpResponse response) throws NikshayApiException { + int status = response.statusCode(); + if (status == 401 || status == 403) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay rejected the login session for the transfer (HTTP " + status + ")"); + } + if (status >= 500) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay transfer returned HTTP " + status); + } + String raw = response.body() == null ? "" : response.body().trim(); + JsonNode body = null; + if (!raw.isEmpty()) { + try { + body = objectMapper.readTree(raw); + } catch (IOException e) { + // An expired session is redirected to the HTML login page instead of answering JSON + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_UNAVAILABLE, + "Ni-kshay transfer answered without JSON; the login session has probably expired"); + } + } + boolean errorReported = body != null && (isFalse(body, "Success") || isFalse(body, "success") + || isError(body.path("error")) || isError(body.path("Error"))); + if (status >= 400 || errorReported) { + throw new NikshayApiException(NikshayFailureReason.TRANSFER_REJECTED, + "Ni-kshay rejected the transfer (HTTP " + status + "): " + message(body, raw)); + } + } + + // UAT https://beta.nikshay.in -> https://registryservice.beta.nikshay.in; + // production https://www.nikshay.in -> https://registryservice.nikshay.in + static String registryUrl(String portalUrl) { + String host = portalUrl.replaceFirst("^https?://", "").replaceFirst("^www\\.", ""); + return "https://registryservice." + host; + } + + private static boolean isFalse(JsonNode body, String field) { + return body.has(field) && body.get(field).isBoolean() && !body.get(field).asBoolean(); + } + + // An "error" field other than false / null / empty + private static boolean isError(JsonNode error) { + if (error.isMissingNode() || error.isNull()) { + return false; + } + if (error.isBoolean()) { + return error.asBoolean(); + } + return !error.asText().isBlank() || error.isObject(); + } + + private static String message(JsonNode body, String raw) { + if (body != null) { + for (String field : new String[] { "ErrorMessage", "Message", "message", "error", "Error" }) { + JsonNode node = body.path(field); + if (node.isTextual() && !node.asText().isBlank()) { + return node.asText(); + } + if (node.isObject() && node.path("Message").isTextual()) { + return node.path("Message").asText(); + } + } + } + return raw.length() > 300 ? raw.substring(0, 300) + "…" : raw; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayBeneficiaryValidator.java b/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayBeneficiaryValidator.java new file mode 100644 index 00000000..4b8c767e --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayBeneficiaryValidator.java @@ -0,0 +1,163 @@ +/* +* 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.admin.service.nikshayidgenerate; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import com.iemr.admin.data.nikshayidgenerate.NikshayIdGenerateBeneficiaryDTO; + +/** + * Checks a beneficiary against the BRD section 13 rules before enrolment in Ni-kshay. + * Auto-fixes (V7) are applied to the beneficiary and logged; every other failed rule + * is returned as a message for staff to rectify. + * + * Not checked here: V1 and V4 (age is worked out from the date of birth, there is no + * separately entered age), V10 (village, checked when grouping by village), V12 and V13 + * (TPT, not for index patients), V14 (needs the Ni-kshay duplicate check) and V15 + * (the eligibility query leaves out beneficiaries that already have a Ni-kshay ID). + */ +@Component +public class NikshayBeneficiaryValidator { + private static final Logger logger = LoggerFactory.getLogger(NikshayBeneficiaryValidator.class); + + // English letters only, words separated by single spaces + private static final String ENGLISH_NAME = "[A-Za-z]+( [A-Za-z]+)*"; + // Printable English (ASCII) characters only, so Hindi script is rejected + private static final String ENGLISH_TEXT = "[\\x20-\\x7E]+"; + private static final int MAX_ADDRESS_LENGTH = 2000; + + /** Returns the problems found, empty when the beneficiary can be enrolled. */ + public List validate(NikshayIdGenerateBeneficiaryDTO beneficiary) { + List problems = new ArrayList<>(); + checkAge(beneficiary, problems); + checkName(beneficiary, problems); + checkPhone(beneficiary, problems); + checkAddress(beneficiary, problems); + checkGenderAndCaste(beneficiary, problems); + checkOtherMandatoryFields(beneficiary, problems); + return problems; + } + + // V2, V3 + private void checkAge(NikshayIdGenerateBeneficiaryDTO beneficiary, List problems) { + Integer age = beneficiary.getAge(); + if (age == null) { + problems.add("V3: Age is missing (no date of birth)"); + } else if (age < 1) { + problems.add("V2: Age is under 1 year; enrol manually as per programme guidance"); + } else if (age > 99) { + problems.add("V3: Age must be 1 to 99 (found " + age + ")"); + } + } + + // V5, V6 + private void checkName(NikshayIdGenerateBeneficiaryDTO beneficiary, List problems) { + String firstName = trim(beneficiary.getFirstName()); + String lastName = trim(beneficiary.getLastName()); + if (firstName == null) { + problems.add("V16: First name is missing"); + } else if (!firstName.matches(ENGLISH_NAME)) { + problems.add("V5: First name must have English letters only (found '" + firstName + "')"); + } + if (lastName == null) { + problems.add("V6: Middle / last name is missing (only one name word)"); + } else if (!lastName.matches(ENGLISH_NAME)) { + problems.add("V5: Middle / last name must have English letters only (found '" + lastName + "')"); + } + } + + // V7 (auto-fix), V8 + private void checkPhone(NikshayIdGenerateBeneficiaryDTO beneficiary, List problems) { + String phone = beneficiary.getPrimaryPhone(); + if (phone == null || phone.isBlank()) { + problems.add("V8: Primary phone is missing"); + return; + } + String cleaned = phone.replaceAll("\\s", ""); + if (cleaned.startsWith("+91")) { + cleaned = cleaned.substring(3); + } + cleaned = cleaned.replaceFirst("^0+", ""); + if (!cleaned.equals(phone)) { + logger.info("V7: Beneficiary {} phone auto-fixed from '{}' to '{}'", beneficiary.getBeneficiaryId(), + phone, cleaned); + beneficiary.setPrimaryPhone(cleaned); + } + if (!cleaned.matches("[0-9]{10}")) { + problems.add("V8: Primary phone must be 10 digits (found '" + phone + "')"); + } + } + + // V9 + private void checkAddress(NikshayIdGenerateBeneficiaryDTO beneficiary, List problems) { + String address = trim(beneficiary.getAddress()); + if (address == null) { + problems.add("V9: Address is missing"); + } else if (address.length() > MAX_ADDRESS_LENGTH) { + problems.add("V9: Address is longer than " + MAX_ADDRESS_LENGTH + " characters"); + } else if (!address.matches(ENGLISH_TEXT)) { + problems.add("V9: Address must be in English"); + } + } + + // V11 + private void checkGenderAndCaste(NikshayIdGenerateBeneficiaryDTO beneficiary, List problems) { + if (trim(beneficiary.getGender()) == null) { + problems.add("V11: Gender is missing"); + } + if (trim(beneficiary.getCaste()) == null) { + problems.add("V11: Caste is missing"); + } + } + + // V16: the other mandatory section 12 fields sent in the enrolment + private void checkOtherMandatoryFields(NikshayIdGenerateBeneficiaryDTO beneficiary, List problems) { + if (trim(beneficiary.getSymptom()) == null) { + problems.add("V16: Symptom is missing"); + } + // Not in the BRD: Ni-kshay rejects the enrolment without it ("Pincode is mandatory"), + // also for contacts; the spec gives 6 digits + String pincode = trim(beneficiary.getPincode()); + if (pincode == null) { + problems.add("V16: Pincode is missing"); + } else if (!pincode.matches("[0-9]{6}")) { + problems.add("V16: Pincode must be 6 digits (found '" + pincode + "')"); + } + if (beneficiary.getNikshayStateId() == null || beneficiary.getNikshayDistrictId() == null + || beneficiary.getNikshayTuId() == null || beneficiary.getSelectedHierarchyId() == null) { + problems.add("V16: Ni-kshay state / district / TU / health facility not found for the village"); + } + } + + // Null when blank + private static String trim(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayEnrollPayloadBuilder.java b/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayEnrollPayloadBuilder.java new file mode 100644 index 00000000..799d0a8a --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayEnrollPayloadBuilder.java @@ -0,0 +1,177 @@ +/* +* 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.admin.service.nikshayidgenerate; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.data.nikshayidgenerate.NikshayHouseholdMemberDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayIdGenerateBeneficiaryDTO; + +/** + * Builds the AddIndiaTB body for an index patient (nikshay-api-spec "MINIMAL NEW-PERSON + * BODY"). With a parentEpisodeId it creates a new episode under that existing record. + * With a sourcePatientId it adds the person as a household contact of that index + * patient (spec "CAPTURED CONTACT ADDINDIATB BODY"). + */ +@Component +public class NikshayEnrollPayloadBuilder { + + private static final DateTimeFormatter DD_MM_YYYY = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + // BRD section 12: Active (Active Case Finding), to confirm with the programme + @Value("${nikshay.enroll.type-of-case-finding:Active (Active Case Finding)}") + private String typeOfCaseFinding; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ObjectNode build(NikshayIdGenerateBeneficiaryDTO beneficiary, String parentEpisodeId, + boolean isPossibleDuplicate, String sourcePatientId) { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("TypeOfPatient", "IndiaTbPublic"); + payload.put("Stage", "PRESUMPTIVE_OPEN"); + payload.put("RegistrationDate", LocalDate.now().format(DD_MM_YYYY)); + payload.put("SelectedHierarchyId", beneficiary.getSelectedHierarchyId()); + payload.put("FirstName", beneficiary.getFirstName().trim()); + payload.put("LastName", beneficiary.getLastName().trim()); + payload.put("FathersName", beneficiary.getFathersName()); + payload.put("Age", String.valueOf(beneficiary.getAge())); + payload.put("Gender", beneficiary.getGender()); + payload.put("PrimaryPhone", beneficiary.getPrimaryPhone()); + payload.putNull("SecondaryPhone1"); + payload.putNull("SecondaryPhone2"); + payload.putNull("SecondaryPhone3"); + payload.put("Address", beneficiary.getAddress().trim()); + payload.putNull("Ward"); + payload.putNull("Taluka"); + payload.putNull("Landmark"); + payload.put("ResidenceHierarchyId", beneficiary.getNikshayPortalVillageId()); + ArrayNode residenceHierarchyAll = payload.putArray("ResidenceHierarchyAll"); + residenceHierarchyAll.add(1); + residenceHierarchyAll.add(beneficiary.getNikshayStateId()); + residenceHierarchyAll.add(beneficiary.getNikshayDistrictId()); + residenceHierarchyAll.add(beneficiary.getNikshayTuId()); + residenceHierarchyAll.add(beneficiary.getNikshayPortalVillageId()); + payload.put("Pincode", beneficiary.getPincode()); + payload.put("Area", beneficiary.getArea()); + payload.put("MaritalStatus", beneficiary.getMaritalStatus()); + payload.put("Occupation", beneficiary.getOccupation()); + payload.put("SocioeconomicStatus", beneficiary.getSocioeconomicStatus()); + payload.put("KeyPopulation", beneficiary.getKeyPopulation()); + payload.putNull("VaccinationDate"); + payload.put("Symptom", beneficiary.getSymptom()); + payload.put("HIVStatus", beneficiary.getHivStatus()); + payload.putNull("ContactPersonPhone"); + payload.putNull("ContactPersonName"); + payload.putNull("ContactPersonAddress"); + if (parentEpisodeId == null) { + payload.putNull("parentEpisodeId"); + } else { + payload.put("parentEpisodeId", Long.parseLong(parentEpisodeId)); + } + payload.put("IsPossibleDuplicate", isPossibleDuplicate); + if (sourcePatientId == null) { + payload.putNull("Source"); + payload.putNull("SourcePatientId"); + } else { + // Contact of the index patient: their episode ID, as a string + payload.put("Source", "contactTracing"); + payload.put("SourcePatientId", sourcePatientId); + } + payload.put("TypeOfCaseFinding", typeOfCaseFinding); + payload.putNull("InformantId"); + payload.put("DateOfBirth", beneficiary.getDateOfBirth()); + payload.putArray("FollowUpsDone"); + payload.putNull("TbWinReferenceId"); + payload.put("caste", beneficiary.getCaste()); + payload.putNull("healthId"); + payload.putNull("healthAddress"); + return payload; + } + + /** + * Builds the ContactTracing body: the index patient's household counts, split at five + * years (same as Q2 of NIKSHAY_ENROLLMENT.sql; no age counts as five or more). Every + * member is counted, also those already in Ni-kshay. The unsplit totals stay null and + * the below-five fields stay null when there is no one under five, as in the capture. + */ + public ObjectNode buildContactTracing(String indexEpisodeId, List members) { + int aboveFive = 0; + int screenedAboveFive = 0; + int belowFive = 0; + int screenedBelowFive = 0; + for (NikshayHouseholdMemberDTO member : members) { + boolean screened = Boolean.TRUE.equals(member.getTbScreened()); + if (member.getAge() != null && member.getAge() < 5) { + belowFive++; + if (screened) { + screenedBelowFive++; + } + } else { + aboveFive++; + if (screened) { + screenedAboveFive++; + } + } + } + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("Id", Long.parseLong(indexEpisodeId)); + for (String field : new String[] { "NumberOfHouseholdContacts", "NumberOfScreened", "NumberOfWithSymptoms", + "NumberOfEvaluated", "NumberOfDiagnosed", "NumberOfOnTreatment", "NumberEligibleForTPT", + "NumberProvidedTPT" }) { + payload.putNull(field); + } + putCounts(payload, "AboveFive", aboveFive, screenedAboveFive); + if (belowFive > 0) { + putCounts(payload, "BelowFive", belowFive, screenedBelowFive); + } else { + for (String field : countFields("BelowFive")) { + payload.putNull(field); + } + } + return payload; + } + + // Contacts and screened as counted; symptoms, evaluated, diagnosed and TPT are 0 as in the capture + private static void putCounts(ObjectNode payload, String band, int contacts, int screened) { + String[] fields = countFields(band); + payload.put(fields[0], contacts); + payload.put(fields[1], screened); + for (int i = 2; i < fields.length; i++) { + payload.put(fields[i], 0); + } + } + + private static String[] countFields(String band) { + return new String[] { "NumberOfHouseholdContacts" + band, "NumberOfScreened" + band, + "NumberOfWithSymptoms" + band, "NumberOfEvaluated" + band, "NumberOfDiagnosed" + band, + "NumberOfOnTreatment" + band, "NumberEligibleForTPT" + band, "NumberProvidedTPT" + band }; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayIdGenerateService.java b/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayIdGenerateService.java new file mode 100644 index 00000000..5ae825c8 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayIdGenerateService.java @@ -0,0 +1,895 @@ +/* +* 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.admin.service.nikshayidgenerate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.data.nikshayidgenerate.NikshayEnrollResult; +import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayFlowScenario; +import com.iemr.admin.data.nikshayidgenerate.NikshayHouseholdMemberDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayIdGenerateBeneficiaryDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayIdGenerateVillageBatch; +import com.iemr.admin.data.nikshayidgenerate.NikshayLinkStatus; +import com.iemr.admin.data.nikshayphasestatus.NikshayBeneficiaryType; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseCandidate; +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; +import com.iemr.admin.repo.nikshayidgenerate.NikshayIdGenerateRepository; +import com.iemr.admin.service.nikshayclient.NikshayApiException; +import com.iemr.admin.service.nikshayclient.NikshayAuthClient; +import com.iemr.admin.service.nikshayclient.NikshayDeviceCredentialService; +import com.iemr.admin.service.nikshayclient.NikshayEnrollClient; +import com.iemr.admin.service.nikshayclient.NikshayLoginException; +import com.iemr.admin.service.nikshayclient.NikshaySession; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; +import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseRun; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseStatusService; +import com.iemr.admin.service.nikshayupload.NikshayTestType; + +/** + * Runs the ENROLMENT and CONTACTS phases of the Ni-kshay flow for Stop TB beneficiaries. + * ENROLMENT gives each eligible beneficiary a Ni-kshay ID and marks their next phases; + * CONTACTS adds an enrolled index patient's household members as their contacts. + */ +@Service +public class NikshayIdGenerateService { + private static final Logger logger = LoggerFactory.getLogger(NikshayIdGenerateService.class); + + // ContactTracing tries before the index's contacts are given up for this run + private static final int CONTACT_COUNTS_ATTEMPTS = 3; + private static final long CONTACT_COUNTS_RETRY_DELAY_MS = 5000; + + @Autowired + private NikshayIdGenerateRepository nikshayIdGenerateRepository; + + @Autowired + private NikshayDeviceCredentialService credentialService; + + @Autowired + private NikshayBeneficiaryValidator beneficiaryValidator; + + @Autowired + private NikshayAuthClient authClient; + + @Autowired + private NikshayEnrollClient enrollClient; + + @Autowired + private NikshayEnrollPayloadBuilder payloadBuilder; + + @Autowired + private NikshayPhaseStatusService phaseStatusService; + + @Autowired + private NikshayJobHistoryLogService logService; + + /** + * ENROLMENT phase: enrols up to limit beneficiaries eligible for a Ni-kshay ID, first the + * TB-positive index patients, then the members of all-negative households. Contacts are + * not added here (CONTACTS phase). With benRegIds, only those beneficiaries are tried, + * whatever their status; without, beneficiaries whose last try failed with an error that + * is not retried automatically are left out until reprocessed. Failures are added to the + * run. + */ + public void runEnrolment(List benRegIds, int limit, NikshayPhaseRun run) { + List eligible = nikshayIdGenerateRepository.findEligibleBeneficiaries(); + logger.info("Found {} beneficiaries eligible for Ni-kshay ID generation", eligible.size()); + List positive = toRun(filterHouseholdScreened(keepRequested(eligible, benRegIds)), + benRegIds, limit); + processBeneficiaries(positive, NikshayFlowScenario.POSITIVE, run); + + // The query already requires the whole household to be screened + List allNegative = nikshayIdGenerateRepository.findAllNegativeBeneficiaries(); + logger.info("Found {} members of all-negative households for Ni-kshay ID generation", allNegative.size()); + processBeneficiaries(toRun(keepRequested(allNegative, benRegIds), benRegIds, limit - positive.size()), + NikshayFlowScenario.ALL_NEGATIVE, run); + } + + // The beneficiaries asked for (all of them when benRegIds is null) + private static List keepRequested(List beneficiaries, + List benRegIds) { + if (benRegIds == null) { + return beneficiaries; + } + List requested = new ArrayList<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + if (benRegIds.contains(beneficiary.getBeneficiaryId())) { + requested.add(beneficiary); + } + } + return requested; + } + + /** + * The first limit beneficiaries to enrol in this run. When the run was not asked for + * given beneficiaries, those waiting for a reprocess are left out first. + */ + private List toRun(List beneficiaries, + List benRegIds, int limit) { + Set waitingForReprocess = new HashSet<>(); + if (benRegIds == null) { + List ids = new ArrayList<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + ids.add(beneficiary.getBeneficiaryId()); + } + waitingForReprocess = phaseStatusService.findWaitingForReprocess(NikshayPhase.ENROLMENT, ids); + if (!waitingForReprocess.isEmpty()) { + logger.info("Leaving out {} beneficiaries waiting for a reprocess", waitingForReprocess.size()); + } + } + List toRun = new ArrayList<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + if (toRun.size() >= limit) { + break; + } + if (!waitingForReprocess.contains(beneficiary.getBeneficiaryId())) { + toRun.add(beneficiary); + } + } + return toRun; + } + + /** + * Keeps only the beneficiaries whose household members have all been TB screened. + * A beneficiary with no household members is kept. + */ + private List filterHouseholdScreened( + List beneficiaries) { + List householdScreened = new ArrayList<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + int notScreened = 0; + for (NikshayHouseholdMemberDTO member : beneficiary.getHouseholdMembers()) { + if (!Boolean.TRUE.equals(member.getTbScreened())) { + notScreened++; + } + } + if (notScreened == 0) { + householdScreened.add(beneficiary); + } else { + logger.info("Skipping beneficiary {}: {} of {} household members not TB screened yet", + beneficiary.getBeneficiaryId(), notScreened, beneficiary.getHouseholdMembers().size()); + phaseStatusService.markPending(beneficiary.getBeneficiaryId(), NikshayBeneficiaryType.INDEX, null, + NikshayPhase.ENROLMENT, notScreened + " of " + beneficiary.getHouseholdMembers().size() + + " household members not TB screened yet"); + } + } + logger.info("{} of {} beneficiaries have their whole household TB screened", householdScreened.size(), + beneficiaries.size()); + return householdScreened; + } + + /** + * Enrols the given beneficiaries: groups them by village, applies the BRD section 13 + * rules and enrols the ones that passed. Failures are added to the run. + */ + public void processBeneficiaries(List beneficiaries, + NikshayFlowScenario flowScenario, NikshayPhaseRun run) { + logger.info("Processing {} beneficiaries for Ni-kshay ID generation ({})", beneficiaries.size(), flowScenario); + if (beneficiaries.isEmpty()) { + return; + } + int failedBefore = run.getErroredBeneficiaries().size(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + beneficiary.setBeneficiaryType(flowScenario == NikshayFlowScenario.POSITIVE ? NikshayBeneficiaryType.INDEX + : NikshayBeneficiaryType.ALL_NEGATIVE); + } + Map batchesByVillage = groupByVillage(beneficiaries, NikshayPhase.ENROLMENT, + run); + logger.info("Grouped beneficiaries into {} village batch(es)", batchesByVillage.size()); + + for (NikshayIdGenerateVillageBatch batch : batchesByVillage.values()) { + List valid = new ArrayList<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : batch.getBeneficiaries()) { + List problems = beneficiaryValidator.validate(beneficiary); + if (problems.isEmpty()) { + valid.add(beneficiary); + } else { + fail(run, beneficiary, NikshayPhase.ENROLMENT, NikshayFailureReason.MANDATORY_FIELD_MISSING, + String.join("; ", problems)); + } + } + batch.setBeneficiaries(valid); + } + + List enrolled = enrollBeneficiaries(batchesByVillage, run, flowScenario); + logger.info("{} beneficiaries enrolled or linked in Ni-kshay", enrolled.size()); + if (flowScenario == NikshayFlowScenario.POSITIVE) { + markContactsOfIndexesNotEnrolled(beneficiaries, enrolled); + } + logger.info("{} beneficiaries cannot be enrolled in Ni-kshay ({})", + run.getErroredBeneficiaries().size() - failedBefore, flowScenario); + } + + /** + * CONTACTS phase: adds the household members of up to limit enrolled index patients to + * Ni-kshay as their contacts. Without benRegIds, the index patients waiting in the phase + * whose enrolment is done; with benRegIds, those index patients whatever their status. + * The index patient's details and household come from the DB, and the contacts go to + * their latest episode. Failures are added to the run. + */ + public void runContacts(List benRegIds, int limit, NikshayPhaseRun run) { + List ids = new ArrayList<>(); + if (benRegIds == null) { + for (NikshayPhaseCandidate candidate : phaseStatusService.findWaiting(NikshayPhase.CONTACTS, limit)) { + ids.add(candidate.getBenRegId()); + } + } else { + // Only index patients have contacts + for (NikshayPhaseCandidate candidate : phaseStatusService.findRows(NikshayPhase.ENROLMENT, benRegIds)) { + if (candidate.getBeneficiaryType() == NikshayBeneficiaryType.INDEX) { + ids.add(candidate.getBenRegId()); + } + } + for (Long benRegId : benRegIds) { + if (!ids.contains(benRegId)) { + addErrored(run, benRegId, NikshayPhase.CONTACTS, null, + "Not an enrolled index patient, so there are no contacts to add"); + } + } + } + logger.info("Adding the contacts of {} index patient(s)", ids.size()); + if (ids.isEmpty()) { + return; + } + + Map episodes = nikshayIdGenerateRepository.findLatestEpisodes(ids); + List indexes = new ArrayList<>(); + for (NikshayIdGenerateBeneficiaryDTO index : nikshayIdGenerateRepository.findBeneficiariesByBenRegIds(ids)) { + index.setBeneficiaryType(NikshayBeneficiaryType.INDEX); + if (episodes.get(index.getBeneficiaryId()) == null) { + fail(run, index, NikshayPhase.CONTACTS, NikshayFailureReason.MANDATORY_FIELD_MISSING, + "The index patient has no Ni-kshay episode saved yet"); + } else if (index.getHouseholdMembers().isEmpty()) { + phaseStatusService.markNotApplicable(index.getBeneficiaryId(), index.getBeneficiaryType(), null, + NikshayPhase.CONTACTS, "No other household members"); + } else { + indexes.add(index); + } + } + + Map batchesByVillage = groupByVillage(indexes, NikshayPhase.CONTACTS, run); + Map sessionsByDevice = new HashMap<>(); + Map loginFailuresByDevice = new HashMap<>(); + for (NikshayIdGenerateVillageBatch batch : batchesByVillage.values()) { + NikshaySession session = login(batch, sessionsByDevice, loginFailuresByDevice, NikshayPhase.CONTACTS, run); + if (session == null || !setPortalVillageId(session, batch, NikshayPhase.CONTACTS, run)) { + continue; + } + for (NikshayIdGenerateBeneficiaryDTO index : batch.getBeneficiaries()) { + addContacts(session, index, episodes.get(index.getBeneficiaryId()), run); + } + } + } + + /** + * Groups the beneficiaries by Ni-kshay village ID, pairing each village with its NAAT + * device login. Beneficiaries with no Ni-kshay village (V10), or whose village has no + * usable NAAT device login, fail the phase instead. + */ + private Map groupByVillage(List beneficiaries, + NikshayPhase phase, NikshayPhaseRun run) { + List villageIds = new ArrayList<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + if (beneficiary.getNikshayVillageId() != null) { + villageIds.add(beneficiary.getNikshayVillageId()); + } + } + Set undecryptableVillages = new HashSet<>(); + Map credentialsByVillage = credentialService.findCredentials(villageIds, + NikshayTestType.NAAT.name(), undecryptableVillages); + + Map batchesByVillage = new LinkedHashMap<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + Integer villageId = beneficiary.getNikshayVillageId(); + if (villageId == null) { + fail(run, beneficiary, phase, NikshayFailureReason.VILLAGE_MISSING, + "V10: Village " + beneficiary.getResidenceHierarchyId() + " is not a Ni-kshay village"); + continue; + } + NikshayDeviceCredentialDTO credential = credentialsByVillage.get(villageId); + if (credential == null) { + if (undecryptableVillages.contains(villageId)) { + fail(run, beneficiary, phase, NikshayFailureReason.DEVICE_CONFIGURATION_MISSING, + "The NAAT device password for Ni-kshay village " + villageId + " could not be decrypted"); + } else { + fail(run, beneficiary, phase, NikshayFailureReason.VILLAGE_MISSING, + "V10: No active NAAT device is mapped to Ni-kshay village " + villageId); + } + continue; + } + NikshayIdGenerateVillageBatch batch = batchesByVillage.get(villageId); + if (batch == null) { + batch = new NikshayIdGenerateVillageBatch(villageId, credential); + batchesByVillage.put(villageId, batch); + } + batch.getBeneficiaries().add(beneficiary); + } + return batchesByVillage; + } + + /** + * The batch's NAAT device login: one login per device, reused for every village the + * device serves. When the login fails, every beneficiary of the batch fails the phase + * and null is returned. + */ + private NikshaySession login(NikshayIdGenerateVillageBatch batch, Map sessionsByDevice, + Map loginFailuresByDevice, NikshayPhase phase, NikshayPhaseRun run) { + NikshayDeviceCredentialDTO credential = batch.getCredential(); + Integer deviceId = credential.getDeviceDetailId(); + if (!sessionsByDevice.containsKey(deviceId) && !loginFailuresByDevice.containsKey(deviceId)) { + try { + sessionsByDevice.put(deviceId, authClient.login(credential.getPhiUserId(), credential.getPassword())); + } catch (NikshayLoginException e) { + loginFailuresByDevice.put(deviceId, e); + } + } + NikshaySession session = sessionsByDevice.get(deviceId); + if (session == null) { + NikshayLoginException e = loginFailuresByDevice.get(deviceId); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : batch.getBeneficiaries()) { + fail(run, beneficiary, phase, e.getReason(), + "NAAT device " + credential.getDeviceSerialNumber() + " login failed: " + e.getMessage()); + } + } + return session; + } + + /** + * Enrols the valid beneficiaries of each village in Ni-kshay with the village's NAAT + * device login, following BRD section 7: duplicate check, then a fresh Ni-kshay ID, the + * existing record or a new episode depending on the duplicate's status. The Ni-kshay ID + * and episode are saved and the beneficiary's next phases marked. Failures are added to + * the run. + */ + private List enrollBeneficiaries(Map batchesByVillage, + NikshayPhaseRun run, NikshayFlowScenario flowScenario) { + List enrolled = new ArrayList<>(); + Map sessionsByDevice = new HashMap<>(); + Map loginFailuresByDevice = new HashMap<>(); + + for (NikshayIdGenerateVillageBatch batch : batchesByVillage.values()) { + if (batch.getBeneficiaries().isEmpty()) { + continue; + } + NikshaySession session = login(batch, sessionsByDevice, loginFailuresByDevice, NikshayPhase.ENROLMENT, run); + if (session == null || !setPortalVillageId(session, batch, NikshayPhase.ENROLMENT, run)) { + continue; + } + Integer deviceId = batch.getCredential().getDeviceDetailId(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : batch.getBeneficiaries()) { + phaseStatusService.markInProgress(beneficiary.getBeneficiaryId(), beneficiary.getBeneficiaryType(), + null, NikshayPhase.ENROLMENT); + NikshayEnrollResult result; + try { + result = enrollBeneficiary(session, beneficiary, deviceId, null, false); + } catch (NikshayApiException e) { + fail(run, beneficiary, NikshayPhase.ENROLMENT, e); + continue; + } + result.setFlowScenario(flowScenario); + saveEnrollment(result); + markEnrolled(beneficiary, result, run); + enrolled.add(result); + } + } + return enrolled; + } + + /** + * Looks up the batch's village on the Ni-kshay portal by name, under the village's + * facility (the master has no Ni-kshay village ID), and sets it on every beneficiary of + * the batch. When the village is not found, or the lookup fails, every beneficiary of + * the batch fails the phase and false is returned. + */ + private boolean setPortalVillageId(NikshaySession session, NikshayIdGenerateVillageBatch batch, NikshayPhase phase, + NikshayPhaseRun run) { + // All beneficiaries of a batch have the same village, so the first one's details serve + NikshayIdGenerateBeneficiaryDTO first = batch.getBeneficiaries().get(0); + NikshayFailureReason failureReason = NikshayFailureReason.VILLAGE_MISSING; + String reason = null; + Long portalVillageId = null; + try { + portalVillageId = enrollClient.findVillageId(session, first.getSelectedHierarchyId(), first.getNikshayTuId(), + first.getNikshayDistrictId(), first.getNikshayVillageName()); + if (portalVillageId == null) { + reason = "V10: Village '" + first.getNikshayVillageName() + "' is not found in Ni-kshay under facility " + + first.getSelectedHierarchyId() + " (TU " + first.getNikshayTuId() + ")"; + } + } catch (NikshayApiException e) { + failureReason = e.getReason(); + reason = "Could not look up village '" + first.getNikshayVillageName() + "' in Ni-kshay: " + e.getMessage(); + } + if (portalVillageId == null) { + for (NikshayIdGenerateBeneficiaryDTO beneficiary : batch.getBeneficiaries()) { + fail(run, beneficiary, phase, failureReason, reason); + } + return false; + } + logger.info("Village {} '{}' is Ni-kshay village {}", batch.getNikshayVillageId(), + first.getNikshayVillageName(), portalVillageId); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : batch.getBeneficiaries()) { + beneficiary.setNikshayPortalVillageId(portalVillageId); + } + return true; + } + + /** + * Adds the index patient's household members to Ni-kshay as their contacts (BRD section + * 8.1, spec Flow 2-A), with the same NAAT device login. First the household counts are + * written on the index patient (ContactTracing), then each member not yet in Ni-kshay + * is validated and enrolled with Source = contactTracing. The counts must go first: + * beta Ni-kshay links a contact to the index only when the index already has them. When + * the counts are not accepted, no contact is added. The contacts are added against the + * index patient's episode; when the index patient is on treatment, against their parent + * ID, and a contact who is already in Ni-kshay is left as is. Failures are added to the + * run under the contact's (or, for the counts, the index patient's) beneficiary ID. The + * index patient's CONTACTS phase and each contact's ENROLMENT phase are recorded. + */ + private void addContacts(NikshaySession session, NikshayIdGenerateBeneficiaryDTO index, NikshayEnrollResult indexResult, + NikshayPhaseRun run) { + phaseStatusService.markInProgress(index.getBeneficiaryId(), index.getBeneficiaryType(), null, + NikshayPhase.CONTACTS); + boolean indexOnTreatment = isOnTreatment(indexResult.getStageFound()); + String sourcePatientId = indexOnTreatment ? indexResult.getNikshayId() : indexResult.getEpisodeId(); + + NikshayApiException countsFailure = updateContactCounts(session, sourcePatientId, index.getHouseholdMembers()); + if (countsFailure != null) { + fail(run, index, NikshayPhase.CONTACTS, countsFailure.getReason(), + "Household contact counts not accepted on index " + sourcePatientId + ": " + + countsFailure.getMessage(), countsFailure); + // Contacts added now would not be linked to the index, so none are added + for (NikshayHouseholdMemberDTO member : index.getHouseholdMembers()) { + if (member.getNikshayId() == null) { + phaseStatusService.markPending(member.getBeneficiaryId(), NikshayBeneficiaryType.CONTACT, + index.getBeneficiaryId(), NikshayPhase.ENROLMENT, "Not added as a contact of index " + + sourcePatientId + " yet: household contact counts not accepted"); + } + } + return; + } + + int added = 0; + int toAdd = 0; + NikshayFailureReason firstFailure = null; + for (NikshayHouseholdMemberDTO member : index.getHouseholdMembers()) { + if (member.getNikshayId() != null) { + continue; + } + toAdd++; + NikshayIdGenerateBeneficiaryDTO contact = toContact(index, member); + List problems = beneficiaryValidator.validate(contact); + if (!problems.isEmpty()) { + fail(run, contact, NikshayPhase.ENROLMENT, NikshayFailureReason.MANDATORY_FIELD_MISSING, + String.join("; ", problems)); + firstFailure = firstFailure == null ? NikshayFailureReason.MANDATORY_FIELD_MISSING : firstFailure; + continue; + } + phaseStatusService.markInProgress(contact.getBeneficiaryId(), contact.getBeneficiaryType(), + contact.getIndexBeneficiaryId(), NikshayPhase.ENROLMENT); + try { + NikshayEnrollResult result = enrollBeneficiary(session, contact, indexResult.getDeviceId(), + sourcePatientId, indexOnTreatment); + if (result == null) { + logger.info("Contact {} of index {} is already in Ni-kshay; index is on treatment, no action", + contact.getBeneficiaryId(), sourcePatientId); + phaseStatusService.markNotApplicable(contact.getBeneficiaryId(), contact.getBeneficiaryType(), + contact.getIndexBeneficiaryId(), NikshayPhase.ENROLMENT, + "Already in Ni-kshay; the index patient is on treatment, so no action"); + continue; + } + result.setFlowScenario(NikshayFlowScenario.POSITIVE); + saveEnrollment(result); + markEnrolled(contact, result, run); + added++; + } catch (NikshayApiException e) { + fail(run, contact, NikshayPhase.ENROLMENT, e); + firstFailure = firstFailure == null ? e.getReason() : firstFailure; + } + } + logger.info("Index {}: {} household contact(s) added or linked in Ni-kshay", sourcePatientId, added); + if (firstFailure == null) { + String note = "Household counts sent; " + added + " contact(s) added or linked"; + phaseStatusService.markDone(index.getBeneficiaryId(), index.getBeneficiaryType(), null, + NikshayPhase.CONTACTS, indexResult.getNikshayId(), sourcePatientId, note); + run.getLogEntries().add(logService.success(run.getContext(), NikshayPhase.CONTACTS.getLogPhase(), + index.getBeneficiaryId(), null, sourcePatientId, note)); + } else { + // The index's own record of it; the contacts' rows have the errors + String detail = (toAdd - added) + " of " + toAdd + " contact(s) not added; see each contact's enrolment"; + phaseStatusService.markFailed(index.getBeneficiaryId(), index.getBeneficiaryType(), null, + NikshayPhase.CONTACTS, firstFailure, detail); + run.getLogEntries().add(logService.failure(run.getContext(), NikshayPhase.CONTACTS.getLogPhase(), + index.getBeneficiaryId(), null, firstFailure, detail)); + } + } + + /** + * Records an enrolled beneficiary's ENROLMENT phase as DONE and marks their next phases. + * Linked to a record that is diagnosed or on treatment (no action): no test upload and + * no transfer out. Otherwise the X-ray upload, and the NAAT upload (MTB and Rif) except + * for an all-negative household (no NAAT), wait for their results. An index patient's contacts + * wait for the CONTACTS phase; a contact or all-negative member has none. + */ + private void markEnrolled(NikshayIdGenerateBeneficiaryDTO beneficiary, NikshayEnrollResult result, + NikshayPhaseRun run) { + Long benRegId = beneficiary.getBeneficiaryId(); + NikshayBeneficiaryType type = beneficiary.getBeneficiaryType(); + Long indexId = beneficiary.getIndexBeneficiaryId(); + String note = result.getLinkStatus() == NikshayLinkStatus.NEW ? null + : result.getLinkStatus() + " (stage '" + result.getStageFound() + "')"; + phaseStatusService.markDone(benRegId, type, indexId, NikshayPhase.ENROLMENT, result.getNikshayId(), + result.getEpisodeId(), note); + run.getLogEntries().add(logService.success(run.getContext(), NikshayPhase.ENROLMENT.getLogPhase(), benRegId, + result.getNikshayId(), result.getEpisodeId(), note == null ? "Enrolled" : note)); + + if (result.getLinkStatus() == NikshayLinkStatus.LINKED_NO_ACTION) { + String noAction = "Already '" + result.getStageFound() + "' in Ni-kshay: no action"; + phaseStatusService.markNotApplicable(benRegId, type, indexId, NikshayPhase.NAAT_UPLOAD, noAction); + phaseStatusService.markNotApplicable(benRegId, type, indexId, NikshayPhase.XRAY_UPLOAD, noAction); + phaseStatusService.markNotApplicable(benRegId, type, indexId, NikshayPhase.TRANSFER_OUT, noAction); + } else { + phaseStatusService.markPending(benRegId, type, indexId, NikshayPhase.XRAY_UPLOAD, + "Waits for a completed chest X-ray result"); + if (type == NikshayBeneficiaryType.ALL_NEGATIVE) { + phaseStatusService.markNotApplicable(benRegId, type, indexId, NikshayPhase.NAAT_UPLOAD, + "All-negative household: no NAAT"); + } else { + phaseStatusService.markPending(benRegId, type, indexId, NikshayPhase.NAAT_UPLOAD, + "Waits for a completed NAAT result, uploaded after the chest X-ray"); + } + } + + if (type == NikshayBeneficiaryType.INDEX) { + phaseStatusService.markPending(benRegId, type, indexId, NikshayPhase.CONTACTS, + "Waits for the CONTACTS phase run"); + } else if (type == NikshayBeneficiaryType.CONTACT) { + phaseStatusService.markNotApplicable(benRegId, type, indexId, NikshayPhase.CONTACTS, + "A contact has no contacts of their own"); + } else { + phaseStatusService.markNotApplicable(benRegId, type, indexId, NikshayPhase.CONTACTS, + "All-negative household: no index patient, so no contacts"); + } + } + + /** + * For each index patient not enrolled in this run, records their household members as + * PENDING contacts ("waits for index patient ... to be enrolled"), so they show in the + * status list instead of being skipped silently. They are not failures of their own, so + * they are not added to the errored list. Left out: members who are index patients + * themselves (they have their own enrolment row), members already in Ni-kshay or + * enrolled in this run, and members already recorded for another index patient of the + * same household. + */ + private void markContactsOfIndexesNotEnrolled(List indexes, + List enrolled) { + Set enrolledIds = new HashSet<>(); + for (NikshayEnrollResult result : enrolled) { + enrolledIds.add(result.getBeneficiaryId()); + } + Set indexIds = new HashSet<>(); + for (NikshayIdGenerateBeneficiaryDTO index : indexes) { + indexIds.add(index.getBeneficiaryId()); + } + Set recorded = new HashSet<>(); + for (NikshayIdGenerateBeneficiaryDTO index : indexes) { + if (enrolledIds.contains(index.getBeneficiaryId()) || index.getHouseholdMembers().isEmpty()) { + continue; + } + phaseStatusService.markPending(index.getBeneficiaryId(), index.getBeneficiaryType(), null, + NikshayPhase.CONTACTS, "Waits for the index patient's enrolment"); + for (NikshayHouseholdMemberDTO member : index.getHouseholdMembers()) { + Long memberId = member.getBeneficiaryId(); + if (member.getNikshayId() != null || indexIds.contains(memberId) || enrolledIds.contains(memberId) + || !recorded.add(memberId)) { + continue; + } + phaseStatusService.markPending(memberId, NikshayBeneficiaryType.CONTACT, index.getBeneficiaryId(), + NikshayPhase.ENROLMENT, "Waits for index patient " + index.getBeneficiaryId() + " to be enrolled"); + } + } + } + + // Adds the failure to the run (response and job history log) and records the phase as FAILED + private void fail(NikshayPhaseRun run, NikshayIdGenerateBeneficiaryDTO beneficiary, NikshayPhase phase, + NikshayFailureReason reason, String detail) { + fail(run, beneficiary, phase, reason, detail, null); + } + + // A failed Ni-kshay call: also keeps what was sent and Ni-kshay's answer in the job history log + private void fail(NikshayPhaseRun run, NikshayIdGenerateBeneficiaryDTO beneficiary, NikshayPhase phase, + NikshayApiException e) { + fail(run, beneficiary, phase, e.getReason(), e.getMessage(), e); + } + + private void fail(NikshayPhaseRun run, NikshayIdGenerateBeneficiaryDTO beneficiary, NikshayPhase phase, + NikshayFailureReason reason, String detail, NikshayApiException e) { + addErrored(run, beneficiary.getBeneficiaryId(), phase, reason, detail); + NikshayJobHistoryLog entry = logService.failure(run.getContext(), phase.getLogPhase(), + beneficiary.getBeneficiaryId(), null, reason, detail); + if (e != null) { + entry.setRequestPayload(e.getRequestPayload()); + entry.setResponseBody(e.getResponseBody()); + } + run.getLogEntries().add(entry); + phaseStatusService.markFailed(beneficiary.getBeneficiaryId(), beneficiary.getBeneficiaryType(), + beneficiary.getIndexBeneficiaryId(), phase, reason, detail); + } + + // Adds the beneficiary to the run's response only + private static void addErrored(NikshayPhaseRun run, Long benRegId, NikshayPhase phase, NikshayFailureReason reason, + String detail) { + NikshayErroredBeneficiaryDTO errored = new NikshayErroredBeneficiaryDTO(benRegId, reason, detail); + errored.setPhase(phase.name()); + run.getErroredBeneficiaries().add(errored); + } + + /** + * Writes the household counts on the index patient (ContactTracing), trying up to + * CONTACT_COUNTS_ATTEMPTS times a few seconds apart: beta Ni-kshay has once answered + * "Failed to update contact tracing details" and accepted the same counts later. + * Returns null when accepted, otherwise the last failure. + */ + private NikshayApiException updateContactCounts(NikshaySession session, String indexId, + List members) { + NikshayApiException lastFailure = null; + for (int attempt = 1; attempt <= CONTACT_COUNTS_ATTEMPTS; attempt++) { + try { + enrollClient.updateContactTracing(session, payloadBuilder.buildContactTracing(indexId, members)); + return null; + } catch (NikshayApiException e) { + lastFailure = e; + logger.warn("Index {}: household contact counts not accepted (attempt {} of {}): {}", indexId, + attempt, CONTACT_COUNTS_ATTEMPTS, e.getMessage()); + } + if (attempt < CONTACT_COUNTS_ATTEMPTS) { + try { + Thread.sleep(CONTACT_COUNTS_RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return lastFailure; + } + } + } + return lastFailure; + } + + /** + * A household member as a beneficiary to enrol as a contact. Their own name, age, + * gender, phone, caste, symptoms and HIV status; the household's address, area, + * socioeconomic status, pincode and Ni-kshay hierarchy from the index patient (beta + * Ni-kshay rejects a contact without a pincode, although the spec capture had null). + */ + private static NikshayIdGenerateBeneficiaryDTO toContact(NikshayIdGenerateBeneficiaryDTO index, + NikshayHouseholdMemberDTO member) { + NikshayIdGenerateBeneficiaryDTO contact = new NikshayIdGenerateBeneficiaryDTO(); + contact.setBeneficiaryId(member.getBeneficiaryId()); + contact.setBeneficiaryType(NikshayBeneficiaryType.CONTACT); + contact.setIndexBeneficiaryId(index.getBeneficiaryId()); + contact.setHouseoldId(member.getHouseoldId()); + contact.setFirstName(member.getFirstName()); + contact.setLastName(member.getLastName()); + contact.setAge(member.getAge()); + contact.setDateOfBirth(member.getDateOfBirth()); + contact.setGender(member.getGender()); + contact.setPrimaryPhone(member.getPrimaryPhone()); + contact.setCaste(member.getCaste()); + // BRD section 12: asymptomatic for TPT, the symptom list (presumptive, V13) otherwise + contact.setSymptom(member.getSymptom()); + contact.setHivStatus(member.getHivStatus()); + contact.setKeyPopulation("Contact of Known TB Patients"); + contact.setMaritalStatus("Unknown"); + contact.setOccupation("Unknown"); + contact.setAddress(index.getAddress()); + contact.setPincode(index.getPincode()); + contact.setArea(index.getArea()); + contact.setSocioeconomicStatus(index.getSocioeconomicStatus()); + contact.setResidenceHierarchyId(index.getResidenceHierarchyId()); + contact.setNikshayVillageId(index.getNikshayVillageId()); + contact.setNikshayVillageName(index.getNikshayVillageName()); + contact.setNikshayPortalVillageId(index.getNikshayPortalVillageId()); + contact.setNikshayStateId(index.getNikshayStateId()); + contact.setNikshayDistrictId(index.getNikshayDistrictId()); + contact.setNikshayTuId(index.getNikshayTuId()); + contact.setSelectedHierarchyId(index.getSelectedHierarchyId()); + return contact; + } + + // 2.5 On treatment; "Diagnosed - not on treatment" also contains "on treatment" + private static boolean isOnTreatment(String stage) { + String normalized = stage == null ? "" : stage.toLowerCase().replaceAll("[^a-z]+", " ").trim(); + return normalized.contains("on treatment") && !normalized.contains("diagnosed") + && !normalized.contains("not on treatment"); + } + + /** + * Enrols one beneficiary (BRD section 7). Runs the duplicate check; with no firm match a + * fresh Ni-kshay ID is created, otherwise the match's status decides what happens. + * With a sourcePatientId the beneficiary is added as that index patient's contact. + * Returns null, with no change in Ni-kshay, when skipIfInNikshay is set and the + * beneficiary is already there. + */ + private NikshayEnrollResult enrollBeneficiary(NikshaySession session, NikshayIdGenerateBeneficiaryDTO beneficiary, + Integer deviceId, String sourcePatientId, boolean skipIfInNikshay) throws NikshayApiException { + List possibleDuplicates = enrollClient.findPossibleDuplicates(session, beneficiary.getFirstName().trim(), + beneficiary.getLastName().trim(), beneficiary.getGender(), beneficiary.getPrimaryPhone(), + beneficiary.getAge(), beneficiary.getDateOfBirth()); + List samePerson = new ArrayList<>(); + for (JsonNode record : possibleDuplicates) { + if (isSamePerson(record, beneficiary)) { + samePerson.add(record); + } + } + + if (skipIfInNikshay && !samePerson.isEmpty()) { + return null; + } + + // Case 1: not in Ni-kshay. Other hits (e.g. same phone only) are sent as a possible duplicate; + // a contact always is, as they usually share the index patient's phone + if (samePerson.isEmpty()) { + ObjectNode payload = payloadBuilder.build(beneficiary, null, + sourcePatientId != null || !possibleDuplicates.isEmpty(), sourcePatientId); + String patientId = enrollClient.addIndiaTb(session, payload); + logger.info("Beneficiary {}: created Ni-kshay ID {}", beneficiary.getBeneficiaryId(), patientId); + return new NikshayEnrollResult(beneficiary.getBeneficiaryId(), patientId, patientId, NikshayLinkStatus.NEW, + null, deviceId); + } + if (samePerson.size() > 1) { + List ids = new ArrayList<>(); + for (JsonNode record : samePerson) { + ids.add(record.path("PatientId").asText()); + } + throw new NikshayApiException(NikshayFailureReason.MULTIPLE_DUPLICATE_MATCHES, + "V14: More than one Ni-kshay record matches this person: " + String.join(", ", ids)); + } + + JsonNode existing = samePerson.get(0); + String existingId = existing.path("PatientId").asText(); + String stage = existing.path("Stage").asText(null); + boolean outcomeAssigned = existing.path("IsPatientOutcomeAssigned").asBoolean(false); + NikshayLinkStatus linkStatus = linkStatusFor(stage, outcomeAssigned); + if (linkStatus == null) { + throw new NikshayApiException(NikshayFailureReason.NIKSHAY_VALIDATION_ERROR, + "V14: Ni-kshay record " + existingId + " has an unknown stage '" + stage + "'; review manually"); + } + + if (linkStatus == NikshayLinkStatus.NEW_EPISODE) { + // Cases 2.2, 2.4, 2.6: new episode under the existing record (parent ID) + ObjectNode payload = payloadBuilder.build(beneficiary, existingId, true, sourcePatientId); + String episodeId = enrollClient.addIndiaTb(session, payload); + logger.info("Beneficiary {}: created episode {} under Ni-kshay ID {} (stage '{}')", + beneficiary.getBeneficiaryId(), episodeId, existingId, stage); + return new NikshayEnrollResult(beneficiary.getBeneficiaryId(), existingId, episodeId, linkStatus, stage, + deviceId); + } + // Case 2.1 (use the existing record), 2.3 and 2.5 (no action, ID only) + logger.info("Beneficiary {}: linked to existing Ni-kshay ID {} as {} (stage '{}')", + beneficiary.getBeneficiaryId(), existingId, linkStatus, stage); + return new NikshayEnrollResult(beneficiary.getBeneficiaryId(), existingId, existingId, linkStatus, stage, + deviceId); + } + + /** + * BRD section 7: what to do with an existing Ni-kshay record, from its Stage and + * IsPatientOutcomeAssigned. Stages seen on beta (2026-10-29): "Presumptive (Open)", + * "Presumptive (Closed)" (outcome assigned), "Diagnosed But Pending Treatment + * (Notified)", "On Treatment (Notified)" and "Outcome Assigned (Notified)" (outcome + * assigned); the spec also shows enum-like "PRESUMPTIVE_OPEN". Null when the stage is + * not known. + */ + private static NikshayLinkStatus linkStatusFor(String stage, boolean outcomeAssigned) { + // 2.6 Outcome assigned + if (outcomeAssigned) { + return NikshayLinkStatus.NEW_EPISODE; + } + String normalized = stage == null ? "" : stage.toLowerCase().replaceAll("[^a-z]+", " ").trim(); + if (normalized.contains("outcome")) { + return NikshayLinkStatus.NEW_EPISODE; + } + if (normalized.contains("presumptive")) { + if (normalized.contains("closed")) { + // 2.2 Presumptive - Closed + return NikshayLinkStatus.NEW_EPISODE; + } + if (normalized.contains("open")) { + // 2.1 Presumptive - Open + return NikshayLinkStatus.LINKED; + } + return null; + } + if (normalized.contains("diagnosed")) { + // 2.4 Diagnosed - not on treatment, closed; 2.3 Diagnosed - not on treatment + return normalized.contains("closed") ? NikshayLinkStatus.NEW_EPISODE : NikshayLinkStatus.LINKED_NO_ACTION; + } + if (normalized.contains("on treatment")) { + // 2.5 On treatment + return NikshayLinkStatus.LINKED_NO_ACTION; + } + return null; + } + + /** + * Same person = same full name, gender and phone, and the same date of birth (or age, + * when either side has no date of birth). The portal's own search is mostly by phone + * and gender, so its hits are narrowed down here (as in the nikshay-enroll worker). + */ + private static boolean isSamePerson(JsonNode record, NikshayIdGenerateBeneficiaryDTO beneficiary) { + String recordName = normalize(record.path("FirstName").asText("") + " " + record.path("LastName").asText("")); + String name = normalize(beneficiary.getFirstName() + " " + beneficiary.getLastName()); + if (!recordName.equals(name)) { + return false; + } + if (!normalize(record.path("Gender").asText("")).equals(normalize(beneficiary.getGender()))) { + return false; + } + String recordPhone = record.hasNonNull("PrimaryPhoneNumber") ? record.get("PrimaryPhoneNumber").asText() + : record.path("PrimaryPhone").asText(""); + if (!recordPhone.replaceAll("\\D", "").equals(beneficiary.getPrimaryPhone())) { + return false; + } + String recordDob = record.path("DateOfBirth").asText("").trim(); + if (!recordDob.isEmpty() && beneficiary.getDateOfBirth() != null) { + return recordDob.equals(beneficiary.getDateOfBirth()); + } + return record.hasNonNull("Age") && beneficiary.getAge() != null + && record.get("Age").asInt() == beneficiary.getAge(); + } + + /** + * Saves the person's Ni-kshay ID on the beneficiary and adds the episode row + * (nikshay_beneficiary_episode). A failure here is only logged: the record is already in + * Ni-kshay, so the next run's duplicate check finds it and links it again. + */ + private void saveEnrollment(NikshayEnrollResult result) { + try { + int updated = nikshayIdGenerateRepository.saveEnrollment(result); + if (updated == 0) { + logger.warn("Beneficiary {}: Ni-kshay ID {} not saved on the beneficiary, identity record not found", + result.getBeneficiaryId(), result.getNikshayId()); + } + } catch (Exception e) { + logger.error("Beneficiary {}: could not save Ni-kshay ID {} / episode {}", result.getBeneficiaryId(), + result.getNikshayId(), result.getEpisodeId(), e); + } + } + + // Case- and whitespace-insensitive form for comparison + private static String normalize(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toLowerCase(); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayFailureReason.java b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayFailureReason.java new file mode 100644 index 00000000..36220164 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayFailureReason.java @@ -0,0 +1,48 @@ +/* +* 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.admin.service.nikshayjoblog; + +/** Failure reasons shown on the Failed Beneficiaries view (BRD section 10.2). */ +public enum NikshayFailureReason { + // Ni-kshay down, timeout or no internet + NIKSHAY_UNAVAILABLE, + // Beneficiary's village is not a Ni-kshay village, or not in any device's village mapping + VILLAGE_MISSING, + // A mandatory Ni-kshay field is blank or fails a rule (V1–V16) + MANDATORY_FIELD_MISSING, + // NAAT or X-ray service not enabled in the device PHI profile + SERVICE_NOT_ENABLED, + // Wrong or expired password for the wrapper PHI login + LOGIN_REJECTED, + // No usable device row or login for the device or activity + DEVICE_CONFIGURATION_MISSING, + // Ni-kshay rejected a value and returned a message + NIKSHAY_VALIDATION_ERROR, + // No active designated PHI for the village (held) + DESIGNATED_PHI_NOT_MAPPED, + // Ni-kshay rejected the transfer + TRANSFER_REJECTED, + // More than one Ni-kshay record matches + MULTIPLE_DUPLICATE_MATCHES, + // The test result is an error / invalid run (e.g. NAAT "Error-2"): not uploaded, waits for a retest + TEST_RESULT_INVALID +} diff --git a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobHistoryLogService.java b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobHistoryLogService.java new file mode 100644 index 00000000..9a9579e0 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobHistoryLogService.java @@ -0,0 +1,132 @@ +/* +* 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.admin.service.nikshayjoblog; + +import java.sql.Timestamp; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.repo.nikshayjoblog.NikshayJobHistoryLogRepo; +import com.iemr.admin.service.nikshayclient.NikshayApiException; + +/** + * Writes and reads the Ni-kshay phase history (nikshay_worker_job_history_log). + * Phases build entries with failure()/success() while they run and hand them to + * saveAll() once, so nothing is dropped silently. + */ +@Service +public class NikshayJobHistoryLogService { + private static final Logger logger = LoggerFactory.getLogger(NikshayJobHistoryLogService.class); + + private static final int MESSAGE_MAX_LENGTH = 1000; + + @Autowired + private NikshayJobHistoryLogRepo logRepo; + + /** A failed entry; also written to the application log as a warning. */ + public NikshayJobHistoryLog failure(NikshayJobRunContext ctx, NikshayPhase phase, Long benId, + String referenceId, NikshayFailureReason reason, String message) { + logger.warn("{} failed for ben {} ({}): {} - {}", phase, benId, referenceId, reason, message); + NikshayJobHistoryLog entry = entry(ctx, phase, benId, referenceId, NikshayLogStatus.FAILED, message); + entry.setReason(reason); + return entry; + } + + /** + * A failed entry for a failed Ni-kshay call, with what was sent and Ni-kshay's full + * answer when the call got that far. + */ + public NikshayJobHistoryLog failure(NikshayJobRunContext ctx, NikshayPhase phase, Long benId, + String referenceId, NikshayApiException e) { + NikshayJobHistoryLog entry = failure(ctx, phase, benId, referenceId, e.getReason(), e.getMessage()); + entry.setRequestPayload(e.getRequestPayload()); + entry.setResponseBody(e.getResponseBody()); + return entry; + } + + /** A success entry, with the Ni-kshay episode ID when the phase has one. */ + public NikshayJobHistoryLog success(NikshayJobRunContext ctx, NikshayPhase phase, Long benId, + String referenceId, String episodeId, String message) { + NikshayJobHistoryLog entry = entry(ctx, phase, benId, referenceId, NikshayLogStatus.SUCCESS, message); + entry.setEpisodeId(episodeId); + return entry; + } + + /** + * Saves the entries in their own transaction, so a rollback of the phase's own + * work doesn't take its history with it. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveAll(List entries) { + if (!entries.isEmpty()) { + logRepo.saveAll(entries); + } + } + + /** Items whose latest attempt failed, newest first. Null filters are ignored. */ + public Page getFailed(NikshayPhase phase, NikshayFailureReason reason, Long benId, + Timestamp fromDate, Timestamp toDate, int page, int size) { + return logRepo.findLatestFailed(NikshayLogStatus.FAILED, phase, reason, benId, fromDate, toDate, + PageRequest.of(page, size)); + } + + /** The given references that already have a SUCCESS entry in this phase (e.g. tests already added). */ + public Set uploadedReferenceIds(NikshayPhase phase, List referenceIds) { + if (referenceIds.isEmpty()) { + return new HashSet<>(); + } + return new HashSet<>(logRepo.findReferenceIdsWithStatus(phase, NikshayLogStatus.SUCCESS, referenceIds)); + } + + /** Every entry of one beneficiary, newest first. */ + public List getHistory(Long benId) { + return logRepo.findByBenIdOrderByIdDesc(benId); + } + + private NikshayJobHistoryLog entry(NikshayJobRunContext ctx, NikshayPhase phase, Long benId, + String referenceId, NikshayLogStatus status, String message) { + NikshayJobHistoryLog entry = new NikshayJobHistoryLog(); + entry.setBenId(benId); + entry.setPhase(phase); + entry.setReferenceId(referenceId); + entry.setStatus(status); + entry.setMessage(message != null && message.length() > MESSAGE_MAX_LENGTH + ? message.substring(0, MESSAGE_MAX_LENGTH) + : message); + entry.setJobRunId(ctx.getJobRunId()); + entry.setTriggerType(ctx.getTriggerType()); + entry.setTriggeredBy(ctx.getTriggeredBy()); + entry.setAttempt((int) logRepo.countAttempts(phase, benId, referenceId) + 1); + return entry; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobRunContext.java b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobRunContext.java new file mode 100644 index 00000000..6cfeb0dc --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobRunContext.java @@ -0,0 +1,40 @@ +/* +* 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.admin.service.nikshayjoblog; + +import java.util.UUID; + +import lombok.Getter; + +/** Identifies one run (job tick or manual push); every log entry of the run carries it. */ +@Getter +public class NikshayJobRunContext { + + private final String jobRunId = UUID.randomUUID().toString(); + private final NikshayTriggerType triggerType; + private final String triggeredBy; + + public NikshayJobRunContext(NikshayTriggerType triggerType, String triggeredBy) { + this.triggerType = triggerType; + this.triggeredBy = triggeredBy; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayLogStatus.java b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayLogStatus.java new file mode 100644 index 00000000..92b7f763 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayLogStatus.java @@ -0,0 +1,27 @@ +/* +* 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.admin.service.nikshayjoblog; + +/** Outcome of one phase attempt. A held beneficiary is FAILED with its reason. */ +public enum NikshayLogStatus { + SUCCESS, FAILED +} diff --git a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayPhase.java b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayPhase.java new file mode 100644 index 00000000..153d957f --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayPhase.java @@ -0,0 +1,27 @@ +/* +* 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.admin.service.nikshayjoblog; + +/** Ni-kshay integration phases, in the order the job runs them. */ +public enum NikshayPhase { + NIKSHAY_ID_GENERATION, NAAT, NAAT_RIF, XRAY, CONTACT_TPT, TRANSFER_OUT +} diff --git a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayTriggerType.java b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayTriggerType.java new file mode 100644 index 00000000..d00b9b1f --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayTriggerType.java @@ -0,0 +1,27 @@ +/* +* 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.admin.service.nikshayjoblog; + +/** What started a run: the scheduled job or a user's manual push. */ +public enum NikshayTriggerType { + JOB, MANUAL +} diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseJob.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseJob.java new file mode 100644 index 00000000..db7df8f7 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseJob.java @@ -0,0 +1,80 @@ +/* +* 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.admin.service.nikshayphasestatus; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.service.nikshayjoblog.NikshayJobRunContext; +import com.iemr.admin.service.nikshayjoblog.NikshayTriggerType; + +/** + * The automatic Ni-kshay job (BRD FR-19, section 11.2), scheduled by + * NikshayJobSchedulingConfig from nikshay.job.cron. Each run goes through the phases in + * order and runs those set to AUTOMATIC, one batch each, as a manual run would. A phase + * that is already running (a manual push) is skipped until the next run; a + * phase that fails does not stop the others. + */ +@Component +public class NikshayPhaseJob { + private static final Logger logger = LoggerFactory.getLogger(NikshayPhaseJob.class); + + // Shown as triggered_by in the job history log + static final String TRIGGERED_BY = "nikshay-job"; + + // Order of a beneficiary's flow: Ni-kshay ID, X-ray, NAAT, contacts, transfer out + static final NikshayPhase[] PHASE_ORDER = { NikshayPhase.ENROLMENT, NikshayPhase.XRAY_UPLOAD, + NikshayPhase.NAAT_UPLOAD, NikshayPhase.CONTACTS, NikshayPhase.TRANSFER_OUT }; + + @Autowired + private NikshayPhaseRunService phaseRunService; + + @Autowired + private NikshayPhaseRunSettingService settingService; + + public void runAutomaticPhases() { + NikshayJobRunContext context = new NikshayJobRunContext(NikshayTriggerType.JOB, TRIGGERED_BY); + logger.info("Ni-kshay job {} started", context.getJobRunId()); + for (NikshayPhase phase : PHASE_ORDER) { + try { + if (!settingService.isAutomatic(phase)) { + logger.info("Ni-kshay job: {} is not automatic, left out", phase); + continue; + } + List errored = phaseRunService.run(phase, null, context); + logger.info("Ni-kshay job: {} done, {} beneficiaries failed", phase, errored.size()); + } catch (IllegalStateException e) { + // Already running (a manual push): the next run picks it up + logger.info("Ni-kshay job: {} skipped: {}", phase, e.getMessage()); + } catch (Exception e) { + logger.error("Ni-kshay job: {} could not run", phase, e); + } + } + logger.info("Ni-kshay job {} finished", context.getJobRunId()); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRun.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRun.java new file mode 100644 index 00000000..8b5b2d40 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRun.java @@ -0,0 +1,46 @@ +/* +* 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.admin.service.nikshayphasestatus; + +import java.util.ArrayList; +import java.util.List; + +import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; +import com.iemr.admin.service.nikshayjoblog.NikshayJobRunContext; + +import lombok.Getter; + +/** + * One run of a Ni-kshay phase: who triggered it, the beneficiaries that failed (the run's + * response) and the job history log entries, written once when the run ends. + */ +@Getter +public class NikshayPhaseRun { + private final NikshayJobRunContext context; + private final List erroredBeneficiaries = new ArrayList<>(); + private final List logEntries = new ArrayList<>(); + + public NikshayPhaseRun(NikshayJobRunContext context) { + this.context = context; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunService.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunService.java new file mode 100644 index 00000000..58747ad1 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunService.java @@ -0,0 +1,131 @@ +/* +* 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.admin.service.nikshayphasestatus; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +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 com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.service.nikshayidgenerate.NikshayIdGenerateService; +import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; +import com.iemr.admin.service.nikshayjoblog.NikshayJobRunContext; +import com.iemr.admin.service.nikshaytransferout.NikshayTransferOutService; +import com.iemr.admin.service.nikshayupload.NikshayUploadService; + +/** + * Runs one Ni-kshay phase: ENROLMENT, XRAY_UPLOAD, NAAT_UPLOAD (MTB and Rif), CONTACTS or + * TRANSFER_OUT. Only one run of a phase at a time; each run takes up to + * nikshay.phase.batch-size beneficiaries and writes its job history log entries when it ends. + */ +@Service +public class NikshayPhaseRunService { + private static final Logger logger = LoggerFactory.getLogger(NikshayPhaseRunService.class); + + @Value("${nikshay.phase.batch-size:200}") + private int batchSize; + + @Autowired + private NikshayIdGenerateService idGenerateService; + + @Autowired + private NikshayUploadService uploadService; + + @Autowired + private NikshayTransferOutService transferOutService; + + @Autowired + private NikshayPhaseStatusService phaseStatusService; + + @Autowired + private NikshayJobHistoryLogService logService; + + // Phases running now (one Admin-API server) + private final Set running = new HashSet<>(); + + /** + * Runs the phase for the beneficiaries waiting in it, or for the given BenRegIds only + * (null = waiting ones). Returns the beneficiaries that failed. Throws + * IllegalStateException when the phase is already running. + */ + public List run(NikshayPhase phase, List benRegIds, + NikshayJobRunContext context) { + synchronized (running) { + if (!running.add(phase)) { + throw new IllegalStateException(phase + " is already running"); + } + } + NikshayPhaseRun run = new NikshayPhaseRun(context); + logger.info("{} run {} started by {} ({})", phase, context.getJobRunId(), context.getTriggeredBy(), + benRegIds == null ? "waiting beneficiaries" : benRegIds.size() + " given beneficiaries"); + try { + switch (phase) { + case ENROLMENT: + idGenerateService.runEnrolment(benRegIds, batchSize, run); + break; + case NAAT_UPLOAD: + case XRAY_UPLOAD: + uploadService.runPhase(phase, benRegIds, batchSize, run); + break; + case CONTACTS: + idGenerateService.runContacts(benRegIds, batchSize, run); + break; + case TRANSFER_OUT: + transferOutService.runTransferOut(benRegIds, batchSize, run); + break; + default: + break; + } + } finally { + synchronized (running) { + running.remove(phase); + } + try { + logService.saveAll(run.getLogEntries()); + } catch (Exception e) { + logger.error("Could not write {} Ni-kshay history log entries", run.getLogEntries().size(), e); + } + } + logger.info("{} run {} finished: {} beneficiaries failed", phase, context.getJobRunId(), + run.getErroredBeneficiaries().size()); + return run.getErroredBeneficiaries(); + } + + /** + * Sets the given beneficiaries back to PENDING in the phase (reprocess); with runNow, + * runs the phase for them straight away and returns the ones that failed. Returns null + * when not run now. + */ + public List reprocess(NikshayPhase phase, List benRegIds, boolean runNow, + NikshayJobRunContext context) { + int count = phaseStatusService.reprocess(phase, benRegIds); + logger.info("{}: {} of {} beneficiaries set for reprocess", phase, count, benRegIds.size()); + return runNow ? run(phase, benRegIds, context) : null; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunSettingService.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunSettingService.java new file mode 100644 index 00000000..cedabae5 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunSettingService.java @@ -0,0 +1,67 @@ +/* +* 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.admin.service.nikshayphasestatus; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseRunSettingDTO; +import com.iemr.admin.data.nikshayphasestatus.NikshayRunMode; +import com.iemr.admin.repo.nikshayphasestatus.NikshayPhaseRunSettingRepository; + +/** The run mode of each Ni-kshay phase (BRD FR-19), set by Admin. */ +@Service +public class NikshayPhaseRunSettingService { + private static final Logger logger = LoggerFactory.getLogger(NikshayPhaseRunSettingService.class); + + @Autowired + private NikshayPhaseRunSettingRepository settingRepository; + + public List findAll() { + return settingRepository.findAll(); + } + + /** Sets the phase's run mode and returns the saved setting. */ + public NikshayPhaseRunSettingDTO setRunMode(NikshayPhase phase, NikshayRunMode runMode, String updatedBy) { + settingRepository.save(phase, runMode, updatedBy); + logger.info("Ni-kshay phase {} set to {} by {}", phase, runMode, updatedBy); + return settingRepository.find(phase); + } + + /** + * Whether the scheduled job runs the phase. A phase with no setting row (V135 not run) + * is not run by the job. + */ + public boolean isAutomatic(NikshayPhase phase) { + NikshayPhaseRunSettingDTO setting = settingRepository.find(phase); + if (setting == null) { + logger.warn("Ni-kshay phase {} has no run mode in nikshay_phase_run_setting; the job leaves it out", phase); + return false; + } + return setting.getRunMode() == NikshayRunMode.AUTOMATIC; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseStatusService.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseStatusService.java new file mode 100644 index 00000000..8faab726 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseStatusService.java @@ -0,0 +1,323 @@ +/* +* 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.admin.service.nikshayphasestatus; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.iemr.admin.data.nikshayphasestatus.NikshayBeneficiaryStatusDTO; +import com.iemr.admin.data.nikshayphasestatus.NikshayBeneficiaryStatusPage; +import com.iemr.admin.data.nikshayphasestatus.NikshayBeneficiaryType; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseCandidate; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseDetailDTO; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseStatus; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseStatusCount; +import com.iemr.admin.repo.nikshayphasestatus.NikshayPhaseStatusRepository; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +/** + * Records where each beneficiary is in each Ni-kshay phase (latest status, latest error, + * attempts) and reads it back for the status list (BRD FR-18 Failed Beneficiaries tab). + * A failure to record a status is only logged: it must never stop the Ni-kshay job. + */ +@Service +public class NikshayPhaseStatusService { + private static final Logger logger = LoggerFactory.getLogger(NikshayPhaseStatusService.class); + + private static final int MAX_ERROR_DETAIL_LENGTH = 2000; + + // Tries of a phase after which a temporary error (Ni-kshay down, login rejected) is no + // longer retried automatically + private static final int MAX_AUTO_ATTEMPTS = 3; + + @Autowired + private NikshayPhaseStatusRepository phaseStatusRepository; + + /** The phase is being tried now; counts one attempt. */ + public void markInProgress(Long benRegId, NikshayBeneficiaryType type, Long indexBenRegId, NikshayPhase phase) { + save(benRegId, type, indexBenRegId, phase, NikshayPhaseStatus.IN_PROGRESS, null, null, true, null, null); + } + + /** The phase finished; note is optional (e.g. "Linked - no action"). */ + public void markDone(Long benRegId, NikshayBeneficiaryType type, Long indexBenRegId, NikshayPhase phase, + String nikshayId, String episodeId, String note) { + save(benRegId, type, indexBenRegId, phase, NikshayPhaseStatus.DONE, null, note, true, nikshayId, episodeId); + } + + /** The phase was tried and failed, with the reason and the error message. */ + public void markFailed(Long benRegId, NikshayBeneficiaryType type, Long indexBenRegId, NikshayPhase phase, + NikshayFailureReason reason, String errorDetail) { + save(benRegId, type, indexBenRegId, phase, NikshayPhaseStatus.FAILED, reason, errorDetail, true, null, null); + } + + /** The phase is due but was not tried; note says why (e.g. household not screened yet). */ + public void markPending(Long benRegId, NikshayBeneficiaryType type, Long indexBenRegId, NikshayPhase phase, + String note) { + save(benRegId, type, indexBenRegId, phase, NikshayPhaseStatus.PENDING, null, note, false, null, null); + } + + /** The phase does not apply to the beneficiary; note says why. */ + public void markNotApplicable(Long benRegId, NikshayBeneficiaryType type, Long indexBenRegId, NikshayPhase phase, + String note) { + save(benRegId, type, indexBenRegId, phase, NikshayPhaseStatus.NOT_APPLICABLE, null, note, false, null, null); + } + + /** + * BRD T4 ("only one of X-ray or NAAT done: push the test available; mark the other Not + * done"): called when one test upload is DONE. When the other test is still PENDING and + * the beneficiary has no order at all for it, it is marked NOT_APPLICABLE "Not done", so + * the beneficiary can go on to transfer out. A test ordered later is not uploaded. + */ + public void markOtherTestNotDone(Long benRegId, Long beneficiaryId, NikshayBeneficiaryType type, + Long indexBenRegId, NikshayPhase donePhase) { + NikshayPhase otherPhase = donePhase == NikshayPhase.XRAY_UPLOAD ? NikshayPhase.NAAT_UPLOAD + : NikshayPhase.XRAY_UPLOAD; + try { + List ids = new ArrayList<>(); + ids.add(benRegId); + List rows = phaseStatusRepository.findRows(otherPhase.name(), ids); + if (beneficiaryId == null || rows.isEmpty() + || !NikshayPhaseStatus.PENDING.name().equals(rows.get(0).getStatus())) { + return; + } + boolean ordered = otherPhase == NikshayPhase.XRAY_UPLOAD ? phaseStatusRepository.hasXrayOrder(beneficiaryId) + : phaseStatusRepository.hasNaatOrder(beneficiaryId); + if (ordered) { + return; + } + } catch (Exception e) { + logger.error("Beneficiary {}: could not check the {} order", benRegId, otherPhase, e); + return; + } + markNotApplicable(benRegId, type, indexBenRegId, otherPhase, "Not done: no " + + (otherPhase == NikshayPhase.XRAY_UPLOAD ? "chest X-ray" : "NAAT") + " order (BRD T4)"); + } + + /** + * Records TRANSFER_OUT as PENDING once both test uploads are DONE or NOT_APPLICABLE and + * the beneficiary has no TRANSFER_OUT row yet (a later test upload, e.g. a Rif result, + * leaves it as it is). The transfer out run decides whether the beneficiary is actually + * transferred. + */ + public void markTransferOutIfTestsDone(Long benRegId, NikshayBeneficiaryType type, Long indexBenRegId) { + try { + List ids = new ArrayList<>(); + ids.add(benRegId); + if (!phaseStatusRepository.findRows(NikshayPhase.TRANSFER_OUT.name(), ids).isEmpty()) { + return; + } + for (NikshayPhase testPhase : new NikshayPhase[] { NikshayPhase.NAAT_UPLOAD, NikshayPhase.XRAY_UPLOAD }) { + List rows = phaseStatusRepository.findRows(testPhase.name(), ids); + if (rows.isEmpty() || !(NikshayPhaseStatus.DONE.name().equals(rows.get(0).getStatus()) + || NikshayPhaseStatus.NOT_APPLICABLE.name().equals(rows.get(0).getStatus()))) { + return; + } + } + } catch (Exception e) { + logger.error("Beneficiary {}: could not read the test upload statuses", benRegId, e); + return; + } + markPending(benRegId, type, indexBenRegId, NikshayPhase.TRANSFER_OUT, + "Tests uploaded; waits for the transfer out run"); + } + + /** + * Up to limit beneficiaries waiting in the phase, oldest first: PENDING, or FAILED with + * an error that is retried automatically (a missing field or an invalid test result every + * run, Ni-kshay down or the login rejected up to MAX_AUTO_ATTEMPTS attempts), and enrolled. + * XRAY_UPLOAD and NAAT_UPLOAD only pick those with a completed result, plus those DONE who + * have a new result not uploaded yet; NAAT_UPLOAD also waits for the chest X-ray upload, + * unless the beneficiary has no X-ray order; neither picks a beneficiary transferred out. + * TRANSFER_OUT picks those whose X-ray and NAAT uploads are both DONE or NOT_APPLICABLE. + * Each phase's query is in NikshayPhaseStatusRepository. ENROLMENT is not picked here. + */ + public List findWaiting(NikshayPhase phase, int limit) { + switch (phase) { + case CONTACTS: + return phaseStatusRepository.findWaitingForContacts(MAX_AUTO_ATTEMPTS, limit); + case XRAY_UPLOAD: + return phaseStatusRepository.findWaitingForXrayUpload(MAX_AUTO_ATTEMPTS, limit); + case NAAT_UPLOAD: + return phaseStatusRepository.findWaitingForNaatUpload(MAX_AUTO_ATTEMPTS, limit); + case TRANSFER_OUT: + return phaseStatusRepository.findWaitingForTransferOut(MAX_AUTO_ATTEMPTS, limit); + default: + throw new IllegalArgumentException("No waiting query for phase " + phase); + } + } + + /** The given beneficiaries whose chest X-ray is still to come or failed, so their NAAT waits. */ + public Set findWaitingForXray(List benRegIds) { + if (benRegIds.isEmpty()) { + return new HashSet<>(); + } + return new HashSet<>(phaseStatusRepository.findWaitingForXray(benRegIds)); + } + + /** The given beneficiaries' rows for the phase; a beneficiary with no row is left out. */ + public List findRows(NikshayPhase phase, List benRegIds) { + if (benRegIds.isEmpty()) { + return new ArrayList<>(); + } + return phaseStatusRepository.findRows(phase.name(), benRegIds); + } + + /** + * The given beneficiaries whose last try of the phase failed with an error that is not + * retried automatically (Ni-kshay rejected it, village or device not set up, or too many + * attempts): they are left out until reprocessed. + */ + public Set findWaitingForReprocess(NikshayPhase phase, List benRegIds) { + if (benRegIds.isEmpty()) { + return new HashSet<>(); + } + return new HashSet<>(phaseStatusRepository.findWaitingForReprocess(phase.name(), benRegIds, MAX_AUTO_ATTEMPTS)); + } + + /** + * Reprocess (BRD FR-11 / FR-18): sets the given beneficiaries back to PENDING in the + * phase, with no attempts, so the next run picks them. Returns how many were set. + */ + public int reprocess(NikshayPhase phase, List benRegIds) { + if (benRegIds.isEmpty()) { + return 0; + } + return phaseStatusRepository.resetToPending(phase.name(), benRegIds, "Set for reprocess"); + } + + private void save(Long benRegId, NikshayBeneficiaryType type, Long indexBenRegId, NikshayPhase phase, + NikshayPhaseStatus status, NikshayFailureReason reason, String detail, boolean countAttempt, + String nikshayId, String episodeId) { + if (benRegId == null) { + return; + } + Timestamp now = new Timestamp(System.currentTimeMillis()); + if (detail != null && detail.length() > MAX_ERROR_DETAIL_LENGTH) { + detail = detail.substring(0, MAX_ERROR_DETAIL_LENGTH); + } + try { + phaseStatusRepository.save(benRegId, type.name(), indexBenRegId, phase.name(), status.name(), + reason == null ? null : reason.name(), detail, countAttempt, nikshayId, episodeId, + countAttempt ? now : null, status == NikshayPhaseStatus.DONE ? now : null); + } catch (Exception e) { + logger.error("Beneficiary {}: could not save Ni-kshay phase {} as {}", benRegId, phase, status, e); + } + } + + /** + * One page of beneficiaries with every phase. All filters are optional; see + * NikshayPhaseStatusRepository.findBeneficiaries. Page numbers start at 0. + */ + public NikshayBeneficiaryStatusPage getStatusList(NikshayPhase phase, String status, boolean failedOnly, + NikshayFailureReason failureReason, NikshayBeneficiaryType beneficiaryType, String search, int page, + int size) { + String phaseName = phase == null ? null : phase.name(); + String reasonName = failureReason == null ? null : failureReason.name(); + String typeName = beneficiaryType == null ? null : beneficiaryType.name(); + String searchText = search == null || search.isBlank() ? null : search.trim(); + + NikshayBeneficiaryStatusPage result = new NikshayBeneficiaryStatusPage(); + result.setPage(page); + result.setSize(size); + result.setTotal(phaseStatusRepository.countBeneficiaries(phaseName, status, failedOnly, reasonName, typeName, + searchText)); + List> rows = phaseStatusRepository.findBeneficiaries(phaseName, status, failedOnly, + reasonName, typeName, searchText, page, size); + if (rows.isEmpty()) { + return result; + } + + Map byBenRegId = new LinkedHashMap<>(); + for (Map row : rows) { + NikshayBeneficiaryStatusDTO dto = new NikshayBeneficiaryStatusDTO(); + dto.setBenRegId(toLong(row.get("ben_reg_id"))); + dto.setBeneficiaryId(toLong(row.get("beneficiary_id"))); + dto.setBeneficiaryType((String) row.get("beneficiary_type")); + dto.setIndexBenRegId(toLong(row.get("index_ben_reg_id"))); + dto.setName((String) row.get("name")); + dto.setIndexName((String) row.get("index_name")); + // Every phase is listed; one without a row has not started + for (NikshayPhase p : NikshayPhase.values()) { + NikshayPhaseDetailDTO detail = new NikshayPhaseDetailDTO(); + detail.setStatus("NOT_STARTED"); + detail.setAttempts(0); + dto.getPhases().put(p.name(), detail); + } + byBenRegId.put(dto.getBenRegId(), dto); + } + + for (Map row : phaseStatusRepository.findPhaseRows(new ArrayList<>(byBenRegId.keySet()))) { + NikshayBeneficiaryStatusDTO dto = byBenRegId.get(toLong(row.get("ben_reg_id"))); + NikshayPhaseDetailDTO detail = dto.getPhases().get((String) row.get("phase")); + if (detail == null) { + continue; + } + detail.setStatus((String) row.get("status")); + detail.setFailureReason((String) row.get("failure_reason")); + detail.setErrorDetail((String) row.get("error_detail")); + detail.setAttempts(((Number) row.get("attempts")).intValue()); + detail.setNikshayId((String) row.get("nikshay_id")); + detail.setEpisodeId((String) row.get("episode_id")); + detail.setLastAttemptAt(toTimestamp(row.get("last_attempt_at"))); + detail.setCompletedAt(toTimestamp(row.get("completed_at"))); + if (NikshayPhase.ENROLMENT.name().equals(row.get("phase"))) { + dto.setNikshayId(detail.getNikshayId()); + } + } + result.getBeneficiaries().addAll(byBenRegId.values()); + return result; + } + + /** Number of beneficiaries per phase and status. */ + public List getSummary() { + return phaseStatusRepository.countByPhaseAndStatus(); + } + + private static Long toLong(Object value) { + return value == null ? null : ((Number) value).longValue(); + } + + // MySQL TIMESTAMP columns come back as LocalDateTime or Timestamp, depending on the driver + private static Timestamp toTimestamp(Object value) { + if (value == null) { + return null; + } + if (value instanceof Timestamp) { + return (Timestamp) value; + } + if (value instanceof java.time.LocalDateTime) { + return Timestamp.valueOf((java.time.LocalDateTime) value); + } + return null; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferOutService.java b/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferOutService.java new file mode 100644 index 00000000..9609cc33 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferOutService.java @@ -0,0 +1,410 @@ +/* +* 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.admin.service.nikshaytransferout; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.data.nikshayidgenerate.NikshayEnrollResult; +import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayIdGenerateBeneficiaryDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayLinkStatus; +import com.iemr.admin.data.nikshayphasestatus.NikshayBeneficiaryType; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseCandidate; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseStatus; +import com.iemr.admin.data.nikshaytransferout.NikshayContactTestsDTO; +import com.iemr.admin.data.nikshaytransferout.NikshayDesignatedPhiDTO; +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; +import com.iemr.admin.repo.nikshayidgenerate.NikshayIdGenerateRepository; +import com.iemr.admin.repo.nikshaytransferout.NikshayTransferOutRepository; +import com.iemr.admin.service.nikshayclient.NikshayApiException; +import com.iemr.admin.service.nikshayclient.NikshayAuthClient; +import com.iemr.admin.service.nikshayclient.NikshayDeviceCredentialService; +import com.iemr.admin.service.nikshayclient.NikshayLoginException; +import com.iemr.admin.service.nikshayclient.NikshaySession; +import com.iemr.admin.service.nikshayclient.NikshayTransferClient; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; +import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseRun; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseStatusService; +import com.iemr.admin.service.nikshayupload.NikshayTestType; + +/** + * TRANSFER_OUT phase (BRD FR-09): transfers the episode of each beneficiary whose tests are + * uploaded to the designated PHI of their village, with the village's NAAT device login. + * Ni-kshay starts the transfer ("Transfer Initiated successfully", IsTransferInProcess) and + * the episode moves once the designated PHI accepts it. + * Who is transferred (BRD 10.1, T5, T7, V13): + * - index patients, for treatment; + * - contacts: with a TB Positive NAAT for treatment, with an X-ray suggestive of TB for + * evaluation, symptomatic ones not at all (not TPT-eligible), the others for TPT; + * - members of all-negative households are not transferred. + * A contact is transferred with the index patient's details (address, phone, village and + * facility), as they were enrolled. + */ +@Service +public class NikshayTransferOutService { + private static final Logger logger = LoggerFactory.getLogger(NikshayTransferOutService.class); + + @Autowired + private NikshayPhaseStatusService phaseStatusService; + + @Autowired + private NikshayTransferOutRepository transferOutRepository; + + @Autowired + private NikshayIdGenerateRepository idGenerateRepository; + + @Autowired + private NikshayDeviceCredentialService credentialService; + + @Autowired + private NikshayAuthClient authClient; + + @Autowired + private NikshayTransferClient transferClient; + + @Autowired + private NikshayTransferPayloadBuilder payloadBuilder; + + @Autowired + private NikshayJobHistoryLogService logService; + + /** One beneficiary to transfer, with everything the call needs. */ + private static class Transfer { + NikshayPhaseCandidate candidate; + NikshayEnrollResult episode; + NikshayDesignatedPhiDTO designatedPhi; + long fromPhi; + long toPhi; + String address; + String phone; + Integer villageId; + // Why they are transferred, e.g. "treatment" or "TPT" + String purpose; + } + + /** + * Transfers up to limit beneficiaries waiting in TRANSFER_OUT, or only the given ones + * (whose X-ray and NAAT uploads must be finished). Failures are added to the run. + */ + public void runTransferOut(List benRegIds, int limit, NikshayPhaseRun run) { + List candidates = findCandidates(benRegIds, limit, run); + logger.info("TRANSFER_OUT: {} beneficiaries to check", candidates.size()); + if (candidates.isEmpty()) { + return; + } + List ids = new ArrayList<>(); + for (NikshayPhaseCandidate candidate : candidates) { + ids.add(candidate.getBenRegId()); + } + Map episodes = idGenerateRepository.findLatestEpisodes(ids); + Set transferred = transferOutRepository.findTransferred(ids); + Map contactTests = transferOutRepository.findContactTests(ids); + + // Whose details go in the call: the beneficiary's own, or a contact's index patient's + List detailIds = new ArrayList<>(); + for (NikshayPhaseCandidate candidate : candidates) { + Long detailId = detailId(candidate); + if (detailId != null && !detailIds.contains(detailId)) { + detailIds.add(detailId); + } + } + Map details = new HashMap<>(); + for (NikshayIdGenerateBeneficiaryDTO beneficiary : idGenerateRepository.findBeneficiariesByBenRegIds(detailIds)) { + details.put(beneficiary.getBeneficiaryId(), beneficiary); + } + + List transfers = new ArrayList<>(); + for (NikshayPhaseCandidate candidate : candidates) { + Long benRegId = candidate.getBenRegId(); + NikshayEnrollResult episode = episodes.get(benRegId); + if (transferred.contains(benRegId)) { + markDone(candidate, episode, "Already transferred out (job history): not sent again"); + continue; + } + if (episode == null) { + fail(run, candidate, NikshayFailureReason.MANDATORY_FIELD_MISSING, + "The beneficiary has no Ni-kshay episode saved yet", null); + continue; + } + if (episode.getLinkStatus() == NikshayLinkStatus.LINKED_NO_ACTION) { + markNotApplicable(candidate, "Already '" + episode.getStageFound() + "' in Ni-kshay: no action"); + continue; + } + String purpose = purpose(candidate, contactTests.get(benRegId)); + if (purpose == null) { + continue; + } + NikshayIdGenerateBeneficiaryDTO detail = details.get(detailId(candidate)); + String missing = missingDetail(detail); + if (missing != null) { + fail(run, candidate, NikshayFailureReason.MANDATORY_FIELD_MISSING, missing, episode.getEpisodeId()); + continue; + } + Transfer transfer = new Transfer(); + transfer.candidate = candidate; + transfer.episode = episode; + transfer.fromPhi = detail.getSelectedHierarchyId(); + transfer.address = detail.getAddress().trim(); + transfer.phone = detail.getPrimaryPhone(); + transfer.villageId = detail.getNikshayVillageId(); + transfer.purpose = purpose; + transfers.add(transfer); + } + if (!transfers.isEmpty()) { + sendTransfers(transfers, run); + } + } + + // Without benRegIds: those waiting in the phase. With benRegIds: their TRANSFER_OUT rows, + // when their tests are uploaded and they are not transferred already + private List findCandidates(List benRegIds, int limit, NikshayPhaseRun run) { + if (benRegIds == null) { + return phaseStatusService.findWaiting(NikshayPhase.TRANSFER_OUT, limit); + } + List candidates = new ArrayList<>(); + Set found = new HashSet<>(); + for (NikshayPhaseCandidate row : phaseStatusService.findRows(NikshayPhase.TRANSFER_OUT, benRegIds)) { + found.add(row.getBenRegId()); + if (NikshayPhaseStatus.DONE.name().equals(row.getStatus())) { + addErrored(run, row.getBenRegId(), null, "Already transferred out"); + } else if (NikshayPhaseStatus.NOT_APPLICABLE.name().equals(row.getStatus())) { + addErrored(run, row.getBenRegId(), null, "Transfer out does not apply to this beneficiary"); + } else { + candidates.add(row); + } + } + for (Long benRegId : benRegIds) { + if (!found.contains(benRegId)) { + addErrored(run, benRegId, null, "X-ray and NAAT uploads are not finished yet"); + } + } + return candidates; + } + + // Why the beneficiary is transferred, or null (and NOT_APPLICABLE) when they are not + private String purpose(NikshayPhaseCandidate candidate, NikshayContactTestsDTO tests) { + NikshayBeneficiaryType type = candidate.getBeneficiaryType(); + if (type == NikshayBeneficiaryType.INDEX) { + return "treatment"; + } + if (type == NikshayBeneficiaryType.ALL_NEGATIVE) { + markNotApplicable(candidate, "All-negative household: no transfer"); + return null; + } + if (tests != null && tests.isNaatPositive()) { + return "treatment (M.Tb. detected)"; + } + if (tests != null && isSuggestiveXray(tests.getLatestXray())) { + return "evaluation (X-ray suggestive of TB)"; + } + if (tests != null && tests.isSymptomatic()) { + markNotApplicable(candidate, "V13: Symptomatic contact, not eligible for TPT: no transfer"); + return null; + } + return "TPT"; + } + + // X-ray "TB Presumptive" (also misspelt "TB Presumtive"): Abnormal - Suggestive of TB + private static boolean isSuggestiveXray(String result) { + String normalized = result == null ? "" : result.trim().replaceAll("\\s+", " ").toLowerCase(); + return normalized.equals("tb presumptive") || normalized.equals("tb presumtive"); + } + + // A contact is transferred with their index patient's details, as they were enrolled + private static Long detailId(NikshayPhaseCandidate candidate) { + return candidate.getBeneficiaryType() == NikshayBeneficiaryType.CONTACT ? candidate.getIndexBenRegId() + : candidate.getBenRegId(); + } + + private static String missingDetail(NikshayIdGenerateBeneficiaryDTO detail) { + if (detail == null) { + return "The beneficiary's (or index patient's) registration details are not found"; + } + if (detail.getSelectedHierarchyId() == null) { + return "No Ni-kshay facility code for the village (hierarchyMappingFrom)"; + } + if (detail.getNikshayVillageId() == null) { + return "V10: The village is not a Ni-kshay village"; + } + if (detail.getAddress() == null || detail.getAddress().isBlank()) { + return "No address (newAddress)"; + } + if (detail.getPrimaryPhone() == null || detail.getPrimaryPhone().isBlank()) { + return "No phone number (mobileNo)"; + } + return null; + } + + // Finds each village's designated PHI and NAAT device login, then sends the transfers + private void sendTransfers(List transfers, NikshayPhaseRun run) { + List villageIds = new ArrayList<>(); + for (Transfer transfer : transfers) { + if (!villageIds.contains(transfer.villageId)) { + villageIds.add(transfer.villageId); + } + } + Map designatedPhis = transferOutRepository.findDesignatedPhis(villageIds); + Set undecryptableVillages = new HashSet<>(); + Map credentials = credentialService.findCredentials(villageIds, + NikshayTestType.NAAT.name(), undecryptableVillages); + + // The transfers to send, by NAAT device (one login per device) + Map> byDevice = new LinkedHashMap<>(); + Map deviceCredentials = new HashMap<>(); + for (Transfer transfer : transfers) { + NikshayDesignatedPhiDTO phi = designatedPhis.get(transfer.villageId); + if (phi == null) { + fail(run, transfer.candidate, NikshayFailureReason.DESIGNATED_PHI_NOT_MAPPED, "S7: No active designated " + + "PHI for Ni-kshay village " + transfer.villageId + ": transfer held until one is mapped", + transfer.episode.getEpisodeId()); + continue; + } + try { + transfer.toPhi = Long.parseLong(phi.getDesignatedPhiId().trim()); + } catch (NumberFormatException e) { + fail(run, transfer.candidate, NikshayFailureReason.DESIGNATED_PHI_NOT_MAPPED, "The designated PHI ID '" + + phi.getDesignatedPhiId() + "' of Ni-kshay village " + transfer.villageId + + " is not a Ni-kshay ID", transfer.episode.getEpisodeId()); + continue; + } + transfer.designatedPhi = phi; + if (transfer.toPhi == transfer.fromPhi) { + markDone(transfer.candidate, transfer.episode, + "Already at the designated PHI " + phiLabel(transfer) + ": nothing sent"); + continue; + } + NikshayDeviceCredentialDTO credential = credentials.get(transfer.villageId); + if (credential == null) { + fail(run, transfer.candidate, undecryptableVillages.contains(transfer.villageId) + ? NikshayFailureReason.DEVICE_CONFIGURATION_MISSING : NikshayFailureReason.VILLAGE_MISSING, + undecryptableVillages.contains(transfer.villageId) + ? "The NAAT device password for Ni-kshay village " + transfer.villageId + + " could not be decrypted" + : "No active NAAT device is mapped to Ni-kshay village " + transfer.villageId, + transfer.episode.getEpisodeId()); + continue; + } + List deviceTransfers = byDevice.get(credential.getDeviceDetailId()); + if (deviceTransfers == null) { + deviceTransfers = new ArrayList<>(); + byDevice.put(credential.getDeviceDetailId(), deviceTransfers); + deviceCredentials.put(credential.getDeviceDetailId(), credential); + } + deviceTransfers.add(transfer); + } + + for (Map.Entry> entry : byDevice.entrySet()) { + NikshayDeviceCredentialDTO credential = deviceCredentials.get(entry.getKey()); + NikshaySession session; + try { + session = authClient.login(credential.getPhiUserId(), credential.getPassword()); + } catch (NikshayLoginException e) { + for (Transfer transfer : entry.getValue()) { + fail(run, transfer.candidate, e.getReason(), "NAAT device " + credential.getDeviceSerialNumber() + + " login failed: " + e.getMessage(), transfer.episode.getEpisodeId()); + } + continue; + } + for (Transfer transfer : entry.getValue()) { + send(session, transfer, run); + } + } + } + + private void send(NikshaySession session, Transfer transfer, NikshayPhaseRun run) { + NikshayPhaseCandidate candidate = transfer.candidate; + String episodeId = transfer.episode.getEpisodeId(); + phaseStatusService.markInProgress(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), NikshayPhase.TRANSFER_OUT); + ObjectNode payload = payloadBuilder.build(episodeId, transfer.fromPhi, transfer.toPhi, transfer.address, + transfer.phone); + String answer; + try { + answer = transferClient.transfer(session, payload); + } catch (NikshayApiException e) { + addErrored(run, candidate.getBenRegId(), e.getReason(), e.getMessage()); + run.getLogEntries().add(logService.failure(run.getContext(), NikshayPhase.TRANSFER_OUT.getLogPhase(), + candidate.getBenRegId(), episodeId, e)); + phaseStatusService.markFailed(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), NikshayPhase.TRANSFER_OUT, e.getReason(), e.getMessage()); + return; + } + // Ni-kshay answers "Transfer Initiated successfully": the episode is marked as transfer in + // process and moves once the designated PHI accepts it + String note = "Transfer initiated from " + transfer.fromPhi + " to " + phiLabel(transfer) + " for " + + transfer.purpose + "; moves once the PHI accepts it"; + logger.info("Beneficiary {}: episode {} {}", candidate.getBenRegId(), episodeId, note); + phaseStatusService.markDone(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), NikshayPhase.TRANSFER_OUT, transfer.episode.getNikshayId(), episodeId, note); + // Ni-kshay's answer is kept while its format is not documented + String message = note + "; Ni-kshay answered " + answer; + run.getLogEntries().add(logService.success(run.getContext(), NikshayPhase.TRANSFER_OUT.getLogPhase(), + candidate.getBenRegId(), episodeId, episodeId, message.length() > 900 ? message.substring(0, 900) : message)); + } + + private static String phiLabel(Transfer transfer) { + String name = transfer.designatedPhi == null ? null : transfer.designatedPhi.getPhiName(); + return name == null ? String.valueOf(transfer.toPhi) : name + " (" + transfer.toPhi + ")"; + } + + private void markDone(NikshayPhaseCandidate candidate, NikshayEnrollResult episode, String note) { + phaseStatusService.markDone(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), NikshayPhase.TRANSFER_OUT, episode == null ? null : episode.getNikshayId(), + episode == null ? null : episode.getEpisodeId(), note); + } + + private void markNotApplicable(NikshayPhaseCandidate candidate, String note) { + phaseStatusService.markNotApplicable(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), NikshayPhase.TRANSFER_OUT, note); + } + + // Adds the failure to the run (response and job history log) and records the phase as FAILED + private void fail(NikshayPhaseRun run, NikshayPhaseCandidate candidate, NikshayFailureReason reason, String detail, + String episodeId) { + addErrored(run, candidate.getBenRegId(), reason, detail); + run.getLogEntries().add(logService.failure(run.getContext(), NikshayPhase.TRANSFER_OUT.getLogPhase(), + candidate.getBenRegId(), episodeId, reason, detail)); + phaseStatusService.markFailed(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), NikshayPhase.TRANSFER_OUT, reason, detail); + } + + // Adds the beneficiary to the run's response only + private static void addErrored(NikshayPhaseRun run, Long benRegId, NikshayFailureReason reason, String detail) { + NikshayErroredBeneficiaryDTO errored = new NikshayErroredBeneficiaryDTO(benRegId, reason, detail); + errored.setPhase(NikshayPhase.TRANSFER_OUT.name()); + run.getErroredBeneficiaries().add(errored); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferPayloadBuilder.java b/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferPayloadBuilder.java new file mode 100644 index 00000000..a98698ae --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferPayloadBuilder.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.admin.service.nikshaytransferout; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Builds the body of Ni-kshay's transfer (registry POST /v1/transfer), as captured from the + * portal (nikshay-api-spec section 7): the episode moves from the PHI it was created under + * to the village's designated PHI, as the current treating PHI. + */ +@Component +public class NikshayTransferPayloadBuilder { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * episodeId: the episode to move. fromPhi: the SelectedHierarchyId it was created under. + * toPhi: the designated PHI. address and phone: as sent at enrolment. + */ + public ObjectNode build(String episodeId, long fromPhi, long toPhi, String address, String phone) { + ObjectNode payload = objectMapper.createObjectNode(); + // A string in the capture, not a number + payload.put("episodeId", episodeId); + payload.put("hierarchyMappingTo", toPhi); + payload.put("hierarchyMappingFrom", fromPhi); + payload.put("newAddress", address); + payload.put("mobileNo", phone); + // The capture used the same number when there is no other + payload.put("alternateMobileNo", phone); + payload.put("reason", "Transfer to higher Center"); + payload.put("actionStatus", "NOACTION"); + payload.put("finalStatus", "NOACTION"); + payload.put("remarks", ""); + // T = transfer (move hierarchy); current = the current treating PHI + payload.put("transferType", "T"); + payload.put("relationType", "current"); + return payload; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/DiagnosticsTestPayloadBuilder.java b/src/main/java/com/iemr/admin/service/nikshayupload/DiagnosticsTestPayloadBuilder.java new file mode 100644 index 00000000..fdb6f016 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayupload/DiagnosticsTestPayloadBuilder.java @@ -0,0 +1,151 @@ +/* +* 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.admin.service.nikshayupload; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; +import com.iemr.admin.data.nikshayupload.NikshayTestResult; +import com.iemr.admin.service.nikshayclient.NikshayApiException; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; + +/** + * The AddDiagnosticsTest payload every test type shares (spec section 6): reason for testing + * ("TB Screening", TrueNat "Diagnosis of TB"), new case, the device's PHI with its district + * and state as testing facility, a result-only post (sample absent, result present, one stub sample), the lab serial + * number from the device's serial number, the result date as test and report date, + * "reported by", the episode as PatientId, plus the result mapped to Ni-kshay's final + * interpretation. Subclasses give the test type, the lab serial prefix and the mapping. + */ +public abstract class DiagnosticsTestPayloadBuilder implements NikshayTestPayloadBuilder { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${nikshay.test.reported-by:By TBMJA application}") + private String reportedBy; + + /** Ni-kshay's TestType, e.g. "Chest X Ray". */ + protected abstract String nikshayTestType(); + + /** Start of the lab serial number, followed by the device serial number, e.g. "SAMPLE-XRAY-". */ + protected abstract String labSerialPrefix(); + + /** EMR Lite result summary (lower case, single spaces) -> Ni-kshay final interpretation. */ + protected abstract Map interpretations(); + + /** Ni-kshay's ReasonForTesting: "TB Screening" unless the test type says otherwise. */ + protected String reasonForTesting() { + return "TB Screening"; + } + + /** Adds the fields only this test type sends (none by default). */ + protected void addTestFields(ObjectNode payload) { + } + + @Override + public final ObjectNode build(NikshayTestResult result, NikshayDeviceCredentialDTO device) throws NikshayApiException { + if (result.getNikshayId() == null || result.getNikshayId().isBlank()) { + throw new NikshayApiException(NikshayFailureReason.MANDATORY_FIELD_MISSING, + "The beneficiary has no Ni-kshay ID yet"); + } + String interpretation = interpretations().get(normalize(result.getResult())); + if (interpretation == null) { + throw new NikshayApiException(NikshayFailureReason.MANDATORY_FIELD_MISSING, getTestType() + " result '" + + result.getResult() + "' can't be mapped to a Ni-kshay interpretation"); + } + long facilityKey = nikshayCode(device.getFacilityNikshayCode(), device.getFacilityName(), "testing facility", + device); + long districtKey = nikshayCode(device.getDistrictNikshayCode(), device.getDistrictName(), "district", device); + long stateKey = nikshayCode(device.getStateNikshayCode(), device.getStateName(), "state", device); + if (device.getDeviceSerialNumber() == null || device.getDeviceSerialNumber().isBlank()) { + throw new NikshayApiException(NikshayFailureReason.DEVICE_CONFIGURATION_MISSING, + "Device " + device.getDeviceDetailId() + " has no serial number for the lab serial number"); + } + // Date the result was saved, as the date tested and reported + String testDate = new SimpleDateFormat("dd-MM-yyyy") + .format(result.getResultDate() != null ? result.getResultDate() : new Date()); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("ReasonForTesting", reasonForTesting()); + payload.putNull("PredominantSymptom"); + payload.put("TypeOfCase", "New"); + payload.putArray("DiagnosisOfTB"); + payload.putArray("TypeOfPresumptiveDRTB"); + payload.put("TestType", nikshayTestType()); + payload.set("TestingFacilityState", keyValue(stateKey, device.getStateName())); + payload.set("TestingFacilityDistrict", keyValue(districtKey, device.getDistrictName())); + ObjectNode facility = payload.putObject("TestingFacility"); + facility.put("Key", facilityKey); + facility.put("Value", device.getFacilityName()); + payload.put("SampleAvailability", "Absent"); + payload.put("ResultAvailability", "Present"); + ObjectNode sample = payload.putArray("TestSampleDetails").addObject(); + sample.put("SampleMappingOption", "Add New Sample"); + sample.put("SampleSpecimenType", "Sputum"); + sample.set("SampleCollectionSiteState", keyValue(stateKey, device.getStateName())); + sample.set("SampleCollectionSiteDistrict", keyValue(districtKey, device.getDistrictName())); + payload.put("LabSerialNumber", labSerialPrefix() + device.getDeviceSerialNumber().trim()); + payload.put("ResultDateTested", testDate); + payload.put("ResultDateReported", testDate); + payload.put("ResultReportedBy", reportedBy); + payload.put("FLLPA_AddMutationDetails", "Skip"); + payload.put("SLLPA_AddMutationDetails", "Skip"); + payload.put("FinalInterpretation", interpretation); + payload.putNull("ChestXRayResult"); + addTestFields(payload); + payload.put("PatientId", result.getNikshayId().trim()); + return payload; + } + + // The spec's geo KeyValue: {"Value": name, "Key": Ni-kshay ID, "Level": 4} + private ObjectNode keyValue(long key, String value) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("Value", value); + node.put("Key", key); + node.put("Level", 4); + return node; + } + + // A Ni-kshay ID of the device PHI's hierarchy, from the master's NikshayCode + private static long nikshayCode(String code, String name, String what, NikshayDeviceCredentialDTO device) + throws NikshayApiException { + try { + if (code != null && name != null) { + return Long.parseLong(code.trim()); + } + } catch (NumberFormatException e) { + // fall through + } + throw new NikshayApiException(NikshayFailureReason.DEVICE_CONFIGURATION_MISSING, + "Device " + device.getDeviceSerialNumber() + " has no valid " + what + " (Ni-kshay code '" + code + "')"); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toLowerCase(); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/NaatTestPayloadBuilder.java b/src/main/java/com/iemr/admin/service/nikshayupload/NaatTestPayloadBuilder.java new file mode 100644 index 00000000..d5a28c15 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayupload/NaatTestPayloadBuilder.java @@ -0,0 +1,70 @@ +/* +* 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.admin.service.nikshayupload; + +import java.util.Map; + +import org.springframework.stereotype.Component; + + +/** + * TrueNat (MTB) payload for Ni-kshay's AddDiagnosticsTest: type "Truenat (MTB)". + * Invalid / error runs are closed and retested by FLW-API, so only detected / not + * detected results reach the upload. + */ +@Component +public class NaatTestPayloadBuilder extends DiagnosticsTestPayloadBuilder { + + // Ni-kshay final interpretation values (programme's mapping table) + static final String DETECTED = "M.Tb. detected"; + static final String NOT_DETECTED = "M.Tb. not detected"; + + private static final Map INTERPRETATIONS = Map.of( + "tb positive", DETECTED, + "tb negative", NOT_DETECTED); + + @Override + public NikshayTestType getTestType() { + return NikshayTestType.NAAT; + } + + @Override + protected String nikshayTestType() { + return "Truenat (MTB)"; + } + + @Override + protected String labSerialPrefix() { + return "SAMPLE-TRUENAT(MTB)-"; + } + + // TrueNat results go to Ni-kshay with reason "Diagnosis of TB" (tested on beta 2026-10-30) + @Override + protected String reasonForTesting() { + return "Diagnosis of TB"; + } + + @Override + protected Map interpretations() { + return INTERPRETATIONS; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestPayloadBuilder.java b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestPayloadBuilder.java new file mode 100644 index 00000000..3b3bf6ae --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestPayloadBuilder.java @@ -0,0 +1,44 @@ +/* +* 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.admin.service.nikshayupload; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; +import com.iemr.admin.data.nikshayupload.NikshayTestResult; +import com.iemr.admin.service.nikshayclient.NikshayApiException; + +/** + * Builds the Ni-kshay "add diagnostics test" payload for one test type. One + * implementation per test type (X-ray, NAAT); NikshayTestResultPusher uses it + * without knowing the type. + */ +public interface NikshayTestPayloadBuilder { + + /** Test type, as the history log records it. */ + NikshayTestType getTestType(); + + /** + * The payload for one result, tested on the given device's facility. Throws + * NikshayApiException when a value Ni-kshay needs is missing or can't be mapped. + */ + ObjectNode build(NikshayTestResult result, NikshayDeviceCredentialDTO device) throws NikshayApiException; +} diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestResultPusher.java b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestResultPusher.java new file mode 100644 index 00000000..b1f7c545 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestResultPusher.java @@ -0,0 +1,132 @@ +/* +* 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.admin.service.nikshayupload; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; +import com.iemr.admin.data.nikshayupload.NikshayTestResult; +import com.iemr.admin.data.nikshayupload.NikshayVillageBatch; +import com.iemr.admin.service.nikshayclient.NikshayAuthClient; +import com.iemr.admin.service.nikshayclient.NikshayDiagnosticsTestClient; +import com.iemr.admin.service.nikshayclient.NikshayDiagnosticsTestClient.AddedTest; +import com.iemr.admin.service.nikshayclient.NikshayLoginException; +import com.iemr.admin.service.nikshayclient.NikshaySession; +import com.iemr.admin.service.nikshayclient.NikshayApiException; +import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; +import com.iemr.admin.service.nikshayjoblog.NikshayJobRunContext; +import com.iemr.admin.service.nikshayjoblog.NikshayPhase; + +/** + * Pushes X-ray or NAAT results to Ni-kshay; the builder decides the payload. + * + * 1. Group the results by device (villages sharing a device share one login). + * 2. For each device: log in with its PHI login, then add each result's test. + * Every result gets a SUCCESS or FAILED history entry. Results already uploaded + * (a SUCCESS entry for the same order) are skipped, so a re-run never adds a test twice. + */ +@Service +public class NikshayTestResultPusher { + private static final Logger logger = LoggerFactory.getLogger(NikshayTestResultPusher.class); + + @Autowired + private NikshayAuthClient authClient; + + @Autowired + private NikshayDiagnosticsTestClient testClient; + + @Autowired + private NikshayJobHistoryLogService logService; + + public void push(Map batchesByVillage, + NikshayTestPayloadBuilder builder, NikshayJobRunContext ctx, List logEntries) { + NikshayPhase phase = builder.getTestType().getPhase(); + Map> resultsByDevice = resultsByDevice(batchesByVillage, phase); + + for (Map.Entry> entry : resultsByDevice.entrySet()) { + NikshayDeviceCredentialDTO device = entry.getKey(); + List results = entry.getValue(); + + NikshaySession session; + try { + session = authClient.login(device.getPhiUserId(), device.getPassword()); + } catch (NikshayLoginException e) { + // Without a login none of this device's results can be sent + for (NikshayTestResult result : results) { + logEntries.add(logService.failure(ctx, phase, result.getBenRegId(), result.getExternalOrderId(), + e.getReason(), "Device " + device.getDeviceSerialNumber() + ": " + e.getMessage())); + } + continue; + } + + logger.info("Pushing {} {} result(s) with device {}", results.size(), phase, device.getDeviceSerialNumber()); + for (NikshayTestResult result : results) { + try { + ObjectNode payload = builder.build(result, device); + AddedTest added = testClient.addDiagnosticsTest(session, payload); + logEntries.add(logService.success(ctx, phase, result.getBenRegId(), result.getExternalOrderId(), + null, "Added by device " + device.getDeviceSerialNumber() + ": RequestId=" + added.getRequestId() + + ", ResultId=" + added.getResultId())); + } catch (NikshayApiException e) { + logEntries.add(logService.failure(ctx, phase, result.getBenRegId(), result.getExternalOrderId(), e)); + } + } + } + } + + /** The results still to upload, grouped by the device whose login sends them. */ + private Map> resultsByDevice( + Map batchesByVillage, NikshayPhase phase) { + List orderIds = batchesByVillage.values().stream().flatMap(b -> b.getResults().stream()) + .map(NikshayTestResult::getExternalOrderId).collect(Collectors.toList()); + Set alreadyUploaded = logService.uploadedReferenceIds(phase, orderIds); + + Map deviceById = new LinkedHashMap<>(); + Map> resultsByDevice = new LinkedHashMap<>(); + for (NikshayVillageBatch batch : batchesByVillage.values()) { + // A device serving several villages has one credential per village (they differ only + // in villageId), so the first one seen is used as that device's key + NikshayDeviceCredentialDTO device = deviceById.computeIfAbsent(batch.getCredential().getDeviceDetailId(), + id -> batch.getCredential()); + for (NikshayTestResult result : batch.getResults()) { + if (alreadyUploaded.contains(result.getExternalOrderId())) { + logger.info("Skipping {} order {}: already uploaded", phase, result.getExternalOrderId()); + } else { + resultsByDevice.computeIfAbsent(device, d -> new ArrayList<>()).add(result); + } + } + } + return resultsByDevice; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestType.java b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestType.java new file mode 100644 index 00000000..75f85dd6 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayTestType.java @@ -0,0 +1,58 @@ +/* +* 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.admin.service.nikshayupload; + +import com.iemr.admin.service.nikshayjoblog.NikshayPhase; + +/** + * Which test's results are uploaded to Ni-kshay: the phase its history log entries are + * recorded under, the device whose login uploads it and the tb_diagnostic_order.order_type + * of its results. NAAT and RIF are both uploaded by the NAAT_UPLOAD phase. + */ +public enum NikshayTestType { + NAAT(NikshayPhase.NAAT, "NAAT", "MTB"), + XRAY(NikshayPhase.XRAY, "XRAY", "XRAY_CHEST"), + // TrueNat Rif resistance, run on the NAAT device + RIF(NikshayPhase.NAAT_RIF, "NAAT", "MDR_RIF"); + + private final NikshayPhase phase; + private final String deviceName; + private final String orderType; + + NikshayTestType(NikshayPhase phase, String deviceName, String orderType) { + this.phase = phase; + this.deviceName = deviceName; + this.orderType = orderType; + } + + public NikshayPhase getPhase() { + return phase; + } + + public String getDeviceName() { + return deviceName; + } + + public String getOrderType() { + return orderType; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/NikshayUploadService.java b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayUploadService.java new file mode 100644 index 00000000..bf49125f --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayUploadService.java @@ -0,0 +1,381 @@ +package com.iemr.admin.service.nikshayupload; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.data.nikshayidgenerate.NikshayEnrollResult; +import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; +import com.iemr.admin.data.nikshayidgenerate.NikshayLinkStatus; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseCandidate; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseStatus; +import com.iemr.admin.data.nikshayupload.NikshayDeviceCredentialDTO; +import com.iemr.admin.data.nikshayupload.NikshayTestResult; +import com.iemr.admin.data.nikshayupload.NikshayVillageBatch; +import com.iemr.admin.repo.nikshayidgenerate.NikshayIdGenerateRepository; +import com.iemr.admin.repo.nikshayupload.DiagnosticOrderRepo; +import com.iemr.admin.service.nikshayclient.NikshayDeviceCredentialService; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; +import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; +import com.iemr.admin.service.nikshayjoblog.NikshayJobRunContext; +import com.iemr.admin.service.nikshayjoblog.NikshayLogStatus; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseRun; +import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseStatusService; + +/** + * Uploads Stop TB test results to Ni-kshay: the NAAT_UPLOAD phase (TrueNat MTB and Rif + * resistance) and the XRAY_UPLOAD phase (chest X-ray). + */ +@Service +public class NikshayUploadService { + private static final Logger logger = LoggerFactory.getLogger(NikshayUploadService.class); + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private NikshayDeviceCredentialService credentialService; + + @Autowired + private NikshayJobHistoryLogService logService; + + @Autowired + private NikshayTestResultPusher testResultPusher; + + @Autowired + private XrayTestPayloadBuilder xrayPayloadBuilder; + + @Autowired + private NaatTestPayloadBuilder naatPayloadBuilder; + + @Autowired + private RifTestPayloadBuilder rifPayloadBuilder; + + @Autowired + private NikshayPhaseStatusService phaseStatusService; + + @Autowired + private NikshayIdGenerateRepository idGenerateRepository; + + /** + * NAAT_UPLOAD phase (TrueNat MTB and Rif resistance results) or XRAY_UPLOAD phase (chest + * X-ray results): uploads the completed results of up to limit enrolled beneficiaries to + * their latest Ni-kshay episode, each with its own Ni-kshay test type. Without benRegIds, + * the beneficiaries waiting in the phase whose enrolment is done and who have a result, + * and those DONE who have a new result (e.g. a Rif result after the MTB one); with + * benRegIds, those beneficiaries whatever their status. A beneficiary linked to a record + * that is diagnosed or on treatment gets no upload (BRD section 7). Error / invalid runs + * are not uploaded. Each beneficiary's phase is DONE when all their results are in + * Ni-kshay (results uploaded before are not sent again), otherwise FAILED with the first + * error. Failures are added to the run; reason for testing is always "TB Screening". + */ + public void runPhase(NikshayPhase phase, List benRegIds, int limit, NikshayPhaseRun run) { + List testTypes = new ArrayList<>(); + if (phase == NikshayPhase.XRAY_UPLOAD) { + testTypes.add(NikshayTestType.XRAY); + } else { + testTypes.add(NikshayTestType.NAAT); + testTypes.add(NikshayTestType.RIF); + } + String testName = phase == NikshayPhase.XRAY_UPLOAD ? "chest X-ray" : "NAAT"; + + List candidates = new ArrayList<>(); + if (benRegIds == null) { + candidates.addAll(phaseStatusService.findWaiting(phase, limit)); + } else { + List enrolledIds = new ArrayList<>(); + for (NikshayPhaseCandidate row : phaseStatusService.findRows(NikshayPhase.ENROLMENT, benRegIds)) { + if (NikshayPhaseStatus.DONE.name().equals(row.getStatus())) { + candidates.add(row); + enrolledIds.add(row.getBenRegId()); + } + } + for (Long benRegId : benRegIds) { + if (!enrolledIds.contains(benRegId)) { + addErrored(run, benRegId, phase, null, "Not enrolled in Ni-kshay yet"); + } + } + // Once transferred out, the episode belongs to the designated PHI: no more uploads + for (NikshayPhaseCandidate row : phaseStatusService.findRows(NikshayPhase.TRANSFER_OUT, enrolledIds)) { + if (NikshayPhaseStatus.DONE.name().equals(row.getStatus())) { + candidates.removeIf(candidate -> candidate.getBenRegId().equals(row.getBenRegId())); + addErrored(run, row.getBenRegId(), phase, null, + "Already transferred out: test results are no longer uploaded"); + } + } + if (phase == NikshayPhase.NAAT_UPLOAD) { + // The NAAT goes after the chest X-ray; the row stays PENDING until then + Set waitingForXray = phaseStatusService.findWaitingForXray(enrolledIds); + candidates.removeIf(candidate -> waitingForXray.contains(candidate.getBenRegId())); + for (Long benRegId : waitingForXray) { + addErrored(run, benRegId, phase, null, + "Chest X-ray not uploaded yet: the NAAT is uploaded after it"); + } + } + } + logger.info("{}: {} beneficiaries to upload", phase, candidates.size()); + if (candidates.isEmpty()) { + return; + } + + List ids = new ArrayList<>(); + for (NikshayPhaseCandidate candidate : candidates) { + ids.add(candidate.getBenRegId()); + } + Map episodes = idGenerateRepository.findLatestEpisodes(ids); + // The beneficiaries to upload, by BeneficiaryID (the test orders' beneficiary_id) + Map toUpload = new LinkedHashMap<>(); + for (NikshayPhaseCandidate candidate : candidates) { + NikshayEnrollResult episode = episodes.get(candidate.getBenRegId()); + if (episode == null) { + fail(run, candidate, phase, NikshayFailureReason.MANDATORY_FIELD_MISSING, + "The beneficiary has no Ni-kshay episode saved yet"); + } else if (episode.getLinkStatus() == NikshayLinkStatus.LINKED_NO_ACTION) { + phaseStatusService.markNotApplicable(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), phase, + "Already '" + episode.getStageFound() + "' in Ni-kshay: no action"); + } else if (candidate.getBeneficiaryId() == null) { + fail(run, candidate, phase, NikshayFailureReason.MANDATORY_FIELD_MISSING, + "The beneficiary's BeneficiaryID is not found"); + } else { + toUpload.put(candidate.getBeneficiaryId(), candidate); + } + } + if (toUpload.isEmpty()) { + return; + } + + // The results to send, by test type; error / invalid runs are not sent (no Ni-kshay + // option, the test is redone), nor results not entered yet + Map> toSendByType = new LinkedHashMap<>(); + Map resultCounts = new HashMap<>(); + Map invalidResults = new HashMap<>(); + for (NikshayTestType testType : testTypes) { + List toSend = new ArrayList<>(); + for (NikshayTestResult result : findResults(testType, new ArrayList<>(toUpload.keySet()))) { + NikshayPhaseCandidate candidate = toUpload.get(result.getBeneficiaryId()); + String summary = result.getResult() == null ? "" : result.getResult().trim(); + if (summary.isEmpty()) { + continue; + } + if (isInvalidResult(summary)) { + invalidResults.put(candidate.getBenRegId(), testType + " result '" + summary + + "' is an error / invalid run: not uploaded, waits for a retest"); + continue; + } + // The test goes to the latest episode, not the Ni-kshay ID + result.setNikshayId(episodes.get(candidate.getBenRegId()).getEpisodeId()); + toSend.add(result); + resultCounts.merge(candidate.getBenRegId(), 1, Integer::sum); + } + toSendByType.put(testType, toSend); + } + for (NikshayPhaseCandidate candidate : toUpload.values()) { + if (resultCounts.containsKey(candidate.getBenRegId())) { + phaseStatusService.markInProgress(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), phase); + } else if (invalidResults.containsKey(candidate.getBenRegId())) { + fail(run, candidate, phase, NikshayFailureReason.TEST_RESULT_INVALID, + invalidResults.get(candidate.getBenRegId())); + } else { + phaseStatusService.markPending(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), phase, "Waits for a completed " + testName + " result"); + } + } + + List logEntries = new ArrayList<>(); + try { + for (NikshayTestType testType : testTypes) { + Map batchesByVillage = prepareBatches(toSendByType.get(testType), testType, + run.getContext(), logEntries); + testResultPusher.push(batchesByVillage, payloadBuilder(testType), run.getContext(), logEntries); + } + } finally { + run.getLogEntries().addAll(logEntries); + } + + // Each beneficiary's first failure; one with none has all their results in Ni-kshay + Map firstFailures = new HashMap<>(); + for (NikshayJobHistoryLog entry : logEntries) { + if (entry.getStatus() == NikshayLogStatus.FAILED && !firstFailures.containsKey(entry.getBenId())) { + firstFailures.put(entry.getBenId(), entry); + } + } + for (NikshayPhaseCandidate candidate : toUpload.values()) { + Integer count = resultCounts.get(candidate.getBenRegId()); + if (count == null) { + continue; + } + NikshayJobHistoryLog failure = firstFailures.get(candidate.getBenRegId()); + if (failure != null) { + addErrored(run, candidate.getBenRegId(), phase, failure.getReason(), failure.getMessage()); + phaseStatusService.markFailed(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), phase, failure.getReason(), failure.getMessage()); + continue; + } + NikshayEnrollResult episode = episodes.get(candidate.getBenRegId()); + String note = count + " " + testName + " result(s) in Ni-kshay"; + if (invalidResults.containsKey(candidate.getBenRegId())) { + note += "; " + invalidResults.get(candidate.getBenRegId()); + } + phaseStatusService.markDone(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), phase, episode.getNikshayId(), episode.getEpisodeId(), note); + phaseStatusService.markOtherTestNotDone(candidate.getBenRegId(), candidate.getBeneficiaryId(), + candidate.getBeneficiaryType(), candidate.getIndexBenRegId(), phase); + phaseStatusService.markTransferOutIfTestsDone(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId()); + } + } + + // The completed results of the test type for the given BeneficiaryIDs + private List findResults(NikshayTestType testType, List beneficiaryIds) { + List results = new ArrayList<>(); + switch (testType) { + case NAAT: + results.addAll(diagnosticOrderRepo.findMtbResults(beneficiaryIds)); + break; + case RIF: + results.addAll(diagnosticOrderRepo.findRifResults(beneficiaryIds)); + break; + case XRAY: + results.addAll(diagnosticOrderRepo.findXrayResults(beneficiaryIds)); + break; + } + return results; + } + + private NikshayTestPayloadBuilder payloadBuilder(NikshayTestType testType) { + switch (testType) { + case NAAT: + return naatPayloadBuilder; + case RIF: + return rifPayloadBuilder; + default: + return xrayPayloadBuilder; + } + } + + // An error / invalid run, e.g. NAAT "Error-2" or X-ray "AI Invalid Result" (BRD mapping table) + private static boolean isInvalidResult(String summary) { + String normalized = normalizeName(summary); + return normalized.startsWith("error") || normalized.contains("invalid"); + } + + // Adds the failure to the run (response and job history log) and records the phase as FAILED + private void fail(NikshayPhaseRun run, NikshayPhaseCandidate candidate, NikshayPhase phase, + NikshayFailureReason reason, String detail) { + addErrored(run, candidate.getBenRegId(), phase, reason, detail); + run.getLogEntries().add(logService.failure(run.getContext(), phase.getLogPhase(), candidate.getBenRegId(), null, + reason, detail)); + phaseStatusService.markFailed(candidate.getBenRegId(), candidate.getBeneficiaryType(), + candidate.getIndexBenRegId(), phase, reason, detail); + } + + // Adds the beneficiary to the run's response only + private static void addErrored(NikshayPhaseRun run, Long benRegId, NikshayPhase phase, NikshayFailureReason reason, + String detail) { + NikshayErroredBeneficiaryDTO errored = new NikshayErroredBeneficiaryDTO(benRegId, reason, detail); + errored.setPhase(phase.name()); + run.getErroredBeneficiaries().add(errored); + } + + /** + * Keeps the results whose village is a Ni-kshay village, finds each village's device + * login and groups the results by village. Every result left out gets a FAILED entry + * in logEntries with its reason, so none is dropped silently. + */ + private Map prepareBatches(List results, + NikshayTestType testType, NikshayJobRunContext ctx, List logEntries) { + List inNikshayVillage = new ArrayList<>(); + for (NikshayTestResult result : results) { + String problem = villageProblem(result); + if (problem == null) { + inNikshayVillage.add(result); + } else { + logEntries.add(logService.failure(ctx, testType.getPhase(), result.getBenRegId(), + result.getExternalOrderId(), NikshayFailureReason.VILLAGE_MISSING, problem)); + } + } + Set undecryptableVillages = new HashSet<>(); + Map credentialsByVillage = credentialService.findCredentials( + inNikshayVillage.stream().map(NikshayTestResult::getVillageId).collect(Collectors.toList()), + testType.getDeviceName(), undecryptableVillages); + return groupByVillage(inNikshayVillage, testType, credentialsByVillage, undecryptableVillages, ctx, + logEntries); + } + + /** + * Groups results by village, pairing each village with its device login. A result + * from a village with no usable login is logged as failed: DEVICE_CONFIGURATION_MISSING + * when the device's password couldn't be decrypted, VILLAGE_MISSING when no active + * device of this type is mapped to the village. + */ + private Map groupByVillage(List results, + NikshayTestType testType, Map credentialsByVillage, + Set undecryptableVillages, NikshayJobRunContext ctx, List logEntries) { + Map batchesByVillage = new LinkedHashMap<>(); + for (NikshayTestResult result : results) { + Integer villageId = result.getVillageId(); + NikshayDeviceCredentialDTO credential = credentialsByVillage.get(villageId); + if (credential == null) { + boolean undecryptable = undecryptableVillages.contains(villageId); + logEntries.add(logService.failure(ctx, testType.getPhase(), result.getBenRegId(), + result.getExternalOrderId(), + undecryptable ? NikshayFailureReason.DEVICE_CONFIGURATION_MISSING + : NikshayFailureReason.VILLAGE_MISSING, + undecryptable + ? "The " + testType + " device password for village " + villageId + + " could not be decrypted" + : "No active " + testType + " device is mapped to village '" + result.getVillage() + + "' (" + villageId + ")")); + continue; + } + batchesByVillage.computeIfAbsent(villageId, + id -> new NikshayVillageBatch(id, result.getVillage(), credential)).getResults().add(result); + } + logger.info("Grouped {} result(s) into {} village batch(es)", + batchesByVillage.values().stream().mapToInt(b -> b.getResults().size()).sum(), + batchesByVillage.size()); + return batchesByVillage; + } + + /** + * Null when the result's village ID is a Ni-kshay village ID, i.e. the village name + * saved at registration matches m_nikshay_village's name for that ID; otherwise the + * reason it isn't. Stop TB registrations before the 2026-07-28 fix saved AMRIT village + * IDs, whose numbers can belong to a different Ni-kshay village; using them would pick + * the wrong device and send the result under the wrong village. + */ + private String villageProblem(NikshayTestResult result) { + if (result.getVillageId() == null) { + return "The beneficiary has no current village"; + } + if (result.getNikshayVillageName() == null) { + return "Village '" + result.getVillage() + "' (" + result.getVillageId() + + ") is not a Ni-kshay village ID"; + } + if (!normalizeName(result.getVillage()).equals(normalizeName(result.getNikshayVillageName()))) { + return "Village '" + result.getVillage() + "' (" + result.getVillageId() + + ") is not a Ni-kshay village; Ni-kshay has '" + result.getNikshayVillageName() + + "' for this ID"; + } + return null; + } + + // Case- and whitespace-insensitive form of a village name for comparison. + private static String normalizeName(String name) { + return name == null ? "" : name.trim().replaceAll("\\s+", " ").toLowerCase(); + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/RifTestPayloadBuilder.java b/src/main/java/com/iemr/admin/service/nikshayupload/RifTestPayloadBuilder.java new file mode 100644 index 00000000..ef763656 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayupload/RifTestPayloadBuilder.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.admin.service.nikshayupload; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.stereotype.Component; + +/** + * TrueNat Rif resistance payload for Ni-kshay's AddDiagnosticsTest: type "Truenat + * (MTB-RIF)". Invalid / error runs are not uploaded (NikshayUploadService). + */ +@Component +public class RifTestPayloadBuilder extends DiagnosticsTestPayloadBuilder { + + // Ni-kshay final interpretation values (programme's mapping table) + static final String RIF_NOT_DETECTED = "M.Tb. detected, Rif resistance not detected"; + static final String RIF_DETECTED = "M.Tb. detected, Rif resistance detected"; + static final String RIF_INDETERMINATE = "M.Tb. detected, Rif resistance indeterminate"; + + // Stop TB app results, short and long forms + private static final Map INTERPRETATIONS = new HashMap<>(); + static { + INTERPRETATIONS.put("non-dr tb", RIF_NOT_DETECTED); + INTERPRETATIONS.put("non-dr tb (rif resistance not detected)", RIF_NOT_DETECTED); + INTERPRETATIONS.put("rif resistance not detected", RIF_NOT_DETECTED); + INTERPRETATIONS.put("dr tb", RIF_DETECTED); + INTERPRETATIONS.put("dr tb (rif resistance detected)", RIF_DETECTED); + INTERPRETATIONS.put("rif resistance detected", RIF_DETECTED); + INTERPRETATIONS.put("indeterminate", RIF_INDETERMINATE); + } + + @Override + public NikshayTestType getTestType() { + return NikshayTestType.RIF; + } + + @Override + protected String nikshayTestType() { + return "Truenat (MTB-RIF)"; + } + + @Override + protected String labSerialPrefix() { + return "SAMPLE-TRUENAT(MTB-RIF)-"; + } + + // TrueNat results go to Ni-kshay with reason "Diagnosis of TB" (tested on beta 2026-10-30) + @Override + protected String reasonForTesting() { + return "Diagnosis of TB"; + } + + @Override + protected Map interpretations() { + return INTERPRETATIONS; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/XrayTestPayloadBuilder.java b/src/main/java/com/iemr/admin/service/nikshayupload/XrayTestPayloadBuilder.java new file mode 100644 index 00000000..1419fb06 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayupload/XrayTestPayloadBuilder.java @@ -0,0 +1,69 @@ +/* +* 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.admin.service.nikshayupload; + +import java.util.Map; + +import org.springframework.stereotype.Component; + + +/** + * Chest X-ray payload for Ni-kshay's AddDiagnosticsTest: type "Chest X Ray", sample + * absent / result present. "AI Invalid Result" has no Ni-kshay option; FLW-API closes + * those orders, so they never reach the upload. + */ +@Component +public class XrayTestPayloadBuilder extends DiagnosticsTestPayloadBuilder { + + // Ni-kshay final interpretation values (programme's mapping table) + static final String NORMAL = "Normal"; + static final String SUGGESTIVE = "Abnormal - Suggestive of TB"; + static final String NOT_SUGGESTIVE = "Abnormal - Not Suggestive of TB"; + + // Includes the misspellings seen in tb_diagnostic_result + private static final Map INTERPRETATIONS = Map.of( + "normal", NORMAL, + "tb presumptive", SUGGESTIVE, + "tb presumtive", SUGGESTIVE, + "abnormal but not tb presumptive", NOT_SUGGESTIVE, + "abnormal but tb not presumptive", NOT_SUGGESTIVE); + + @Override + public NikshayTestType getTestType() { + return NikshayTestType.XRAY; + } + + @Override + protected String nikshayTestType() { + return "Chest X Ray"; + } + + @Override + protected Map interpretations() { + return INTERPRETATIONS; + } + + @Override + protected String labSerialPrefix() { + return "SAMPLE-XRAY-"; + } +}