From 341a34f252bc78d28e2fd1dfbb2618d984dc9d56 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 22 Jun 2026 12:20:53 +0530 Subject: [PATCH 01/47] Enforced mandatory validation for "Reason for Refusal in section e in file TbCounsellingFormSeeder --- src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java index d3a2c83e..6c0a248c 100644 --- a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java +++ b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java @@ -185,7 +185,7 @@ private FormSectionDTO buildSectionE() { qs.add(statusQ); // Reason for refusal — hidden by default, max 300 chars - qs.add(textQuestion("TB_E_REFUSAL", "Reason for refusal", 2, false, 300, false)); + qs.add(textQuestion("TB_E_REFUSAL", "Reason for refusal", 2, true, 300, false)); // Counsellor remarks — optional, max 500 chars qs.add(textQuestion("TB_E_REMARKS", "Counsellor remarks", 3, false, 500, true)); From 2908c20f8cbe7a0e3d59149a1ca5cc8746dedc08 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 23 Jun 2026 08:53:41 +0530 Subject: [PATCH 02/47] Added counselled flag to confirmed cases response, indicating whether a beneficiary has completed the TB Counselling form. Implemented using a single bulk query to avoid N+1 lookups against FormResponse. --- .../DynamicFormResponseController.java | 6 ++- .../flw/domain/iemr/TBConfirmedCaseDTO.java | 1 + .../iemr/flw/repo/iemr/FormResponseRepo.java | 8 ++++ .../service/DynamicFormResponseService.java | 3 +- .../impl/DynamicFormResponseServiceImpl.java | 40 ++++++++++++------- .../service/impl/FormResponseItemSaver.java | 20 +++++----- .../impl/TBConfirmedCaseServiceImpl.java | 33 ++++++++++++--- 7 files changed, 77 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java index 636c6bcf..78a3dd83 100644 --- a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java +++ b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java @@ -25,6 +25,7 @@ import com.iemr.flw.dto.iemr.FormResponseRequest; import com.iemr.flw.service.DynamicFormResponseService; import com.iemr.flw.utils.ApiResponse; +import com.iemr.flw.utils.exception.IEMRException; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -64,8 +65,9 @@ public ResponseEntity submitForm( @RequestMapping(value = "/complete", method = RequestMethod.POST) public ResponseEntity completeForm( @RequestParam Long responseId, - @Valid @RequestBody FormResponseRequest request) { - FormResponseDTO dto = responseService.completeForm(responseId, request); + @Valid @RequestBody FormResponseRequest request, + @RequestHeader("JwtToken") String jwtToken) throws IEMRException { + FormResponseDTO dto = responseService.completeForm(responseId, request, jwtToken); return ResponseEntity.ok(new ApiResponse(true, "Form completed successfully", dto)); } diff --git a/src/main/java/com/iemr/flw/domain/iemr/TBConfirmedCaseDTO.java b/src/main/java/com/iemr/flw/domain/iemr/TBConfirmedCaseDTO.java index 07903035..7a521177 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/TBConfirmedCaseDTO.java +++ b/src/main/java/com/iemr/flw/domain/iemr/TBConfirmedCaseDTO.java @@ -29,5 +29,6 @@ public class TBConfirmedCaseDTO { private String placeOfDeath; private String reasonForDeath ; private String reasonForNotCompleting; + private Boolean counselled; } diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java index b3c1d22f..830cad08 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java @@ -42,6 +42,14 @@ public interface FormResponseRepo extends JpaRepository { List findByBeneficiaryId(Long beneficiaryId); + @Query("SELECT r.beneficiaryId FROM FormResponse r " + + "WHERE r.beneficiaryId IN :benIds " + + "AND r.formId = :formId " + + "AND r.status = :status") + List findCounselledBenIds(@Param("benIds") List benIds, + @Param("formId") Long formId, + @Param("status") String status); + @Query("SELECT r FROM FormResponse r " + "WHERE r.formId IN :formIds " + "AND r.status = 'SUBMITTED' " diff --git a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java index 9e92f98e..7a07f23f 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java @@ -23,6 +23,7 @@ import com.iemr.flw.dto.iemr.FormResponseDTO; import com.iemr.flw.dto.iemr.FormResponseRequest; +import com.iemr.flw.utils.exception.IEMRException; import java.util.List; @@ -37,7 +38,7 @@ public interface DynamicFormResponseService { FormResponseDTO submitForm(FormResponseRequest request); /** Save POST_SUBMIT section answers and advance status to COMPLETE. */ - FormResponseDTO completeForm(Long responseId, FormResponseRequest request); + FormResponseDTO completeForm(Long responseId, FormResponseRequest request, String jwtToken) throws IEMRException; /** All responses for a beneficiary filtered by form UUID. */ List getResponsesByBeneficiary(Long beneficiaryId, String formUuid); diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index c3649990..4d1df037 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -43,6 +43,8 @@ import com.iemr.flw.repo.iemr.SectionResponseRepo; import com.iemr.flw.masterEnum.QuestionType; import com.iemr.flw.service.DynamicFormResponseService; +import com.iemr.flw.utils.JwtUtil; +import com.iemr.flw.utils.exception.IEMRException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -83,6 +85,7 @@ public class DynamicFormResponseServiceImpl implements DynamicFormResponseServic private final SectionQuestionRepo sectionQuestionRepo; private final QuestionOptionRepo questionOptionRepo; private final FormResponseItemSaver itemSaver; + private final JwtUtil jwtUtil; @Override @Transactional @@ -107,18 +110,20 @@ public FormResponseDTO submitForm(FormResponseRequest request) { } else { formResponse = createFormResponse(request, version, STATUS_SUBMITTED, now); } - return processSections(formResponse, request, version); + return processSections(formResponse, request, version, null); } @Override @Transactional - public FormResponseDTO completeForm(Long responseId, FormResponseRequest request) { + public FormResponseDTO completeForm(Long responseId, FormResponseRequest request, String jwtToken) throws IEMRException { + String actor = jwtUtil.extractUserId(jwtToken).toString(); FormResponse formResponse = formResponseRepo.findById(responseId) .orElseThrow(() -> new NoSuchElementException("FormResponse not found: " + responseId)); if (!STATUS_SUBMITTED.equals(formResponse.getStatus())) { throw new IllegalStateException( "Cannot complete a response in status: " + formResponse.getStatus()); } + formResponse.setUpdatedBy(actor); formResponse.setStatus(STATUS_COMPLETE); formResponse.setCompletedAt(new Timestamp(System.currentTimeMillis())); formResponse = formResponseRepo.save(formResponse); @@ -127,7 +132,7 @@ public FormResponseDTO completeForm(Long responseId, FormResponseRequest request FormVersion version = formVersionRepo.findById(formResponse.getVersionId()) .orElseThrow(() -> new NoSuchElementException( "FormVersion not found: " + finalFormResponse.getVersionId())); - return processSections(formResponse, request, version); + return processSections(formResponse, request, version, actor); } @Override @@ -187,7 +192,8 @@ private FormResponse createFormResponse( private FormResponseDTO processSections( FormResponse formResponse, FormResponseRequest request, - FormVersion version) { + FormVersion version, + String actor) { List allSections = formSectionRepo.findByFormVersion_VersionIdOrderByDisplayOrderAsc(version.getVersionId()); @@ -231,7 +237,7 @@ private FormResponseDTO processSections( } SectionResponse sectionResponse = upsertSectionResponse( - formResponse.getResponseId(), section.getSectionId()); + formResponse.getResponseId(), section.getSectionId(), actor); if ("POST_SUBMIT".equals(section.getSectionPhase())) { formResponse.setLastFollowUpAt(sectionResponse.getSavedAt()); @@ -245,7 +251,8 @@ private FormResponseDTO processSections( sectionResponse.getSectionResponseId(), sectionReq.getAnswers(), questionMap, - optionsByQuestion); + optionsByQuestion, + actor); questionResponseRepo.saveAll(questionResponses); @@ -255,13 +262,14 @@ private FormResponseDTO processSections( return buildFormResponseDTO(formResponse, sectionDTOs); } - private SectionResponse upsertSectionResponse(Long responseId, Long sectionId) { + private SectionResponse upsertSectionResponse(Long responseId, Long sectionId, String actor) { Optional existing = sectionResponseRepo.findByResponseIdAndSectionId(responseId, sectionId); if (existing.isPresent()) { SectionResponse sr = existing.get(); sr.setStatus(SECTION_STATUS_DONE); sr.setSavedAt(new Timestamp(System.currentTimeMillis())); + sr.setUpdatedBy(actor); return sectionResponseRepo.save(sr); } SectionResponse sr = SectionResponse.builder() @@ -269,9 +277,8 @@ private SectionResponse upsertSectionResponse(Long responseId, Long sectionId) { .sectionId(sectionId) .status(SECTION_STATUS_DONE) .savedAt(new Timestamp(System.currentTimeMillis())) - // TODO: set audit fields when auth is re-enabled for /dynamicForm/response endpoints - // .createdBy(jwtUtil.extractUsername(authorization)) - // .updatedBy(jwtUtil.extractUsername(authorization)) + .createdBy(actor) + .updatedBy(actor) .build(); return sectionResponseRepo.save(sr); } @@ -280,7 +287,8 @@ private List processAnswers( Long sectionResponseId, List answers, Map questionMap, - Map> optionsByQuestion) { + Map> optionsByQuestion, + String actor) { List results = new ArrayList<>(); @@ -305,10 +313,6 @@ private List processAnswers( // Delete any existing answers for this question in this section (handles re-saves) questionResponseRepo.deleteByQuestionIdAndSectionResponseId(questionId, sectionResponseId); - // TODO: set .createdBy / .updatedBy on all QuestionResponse builders below - // when auth is re-enabled for /dynamicForm/response endpoints: - // .createdBy(jwtUtil.extractUsername(authorization)) - // .updatedBy(jwtUtil.extractUsername(authorization)) if (type == QuestionType.RADIO) { if (answer.getOptionValue() != null) { QuestionOption opt = resolveOption( @@ -317,6 +321,8 @@ private List processAnswers( .sectionResponseId(sectionResponseId) .questionId(questionId) .optionId(opt != null ? opt.getOptionId() : null) + .createdBy(actor) + .updatedBy(actor) .build()); } } else if (type == QuestionType.MCQ) { @@ -328,6 +334,8 @@ private List processAnswers( .sectionResponseId(sectionResponseId) .questionId(questionId) .optionId(opt != null ? opt.getOptionId() : null) + .createdBy(actor) + .updatedBy(actor) .build()); } } @@ -340,6 +348,8 @@ private List processAnswers( .sectionResponseId(sectionResponseId) .questionId(questionId) .answerText(value) + .createdBy(actor) + .updatedBy(actor) .build()); } } diff --git a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java index 08cb34ae..0d844eb7 100644 --- a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java +++ b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java @@ -108,17 +108,7 @@ public FormResponseDTO saveForBulk(FormResponseRequest req, String jwtToken) thr formResponse = existing.get(0); Long responseId = formResponse.getResponseId(); - // Step 3: Delete old answers — children first, then parents - List oldSections = sectionResponseRepo.findByResponseId(responseId); - if (!oldSections.isEmpty()) { - Set oldSrIds = oldSections.stream() - .map(SectionResponse::getSectionResponseId) - .collect(Collectors.toSet()); - questionResponseRepo.deleteBySectionResponseIdIn(oldSrIds); - sectionResponseRepo.deleteByResponseId(responseId); - } - - // Step 4: Update FormResponse header + // Step 3: Update FormResponse header formResponse.setStatus(STATUS_SUBMITTED); formResponse.setSubmittedAt(now); formResponse.setUpdatedBy(actor); @@ -234,6 +224,14 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR "' not found in form '" + req.getFormUuid() + "'"); } + // Delete existing SectionResponse (and its QuestionResponses) for this section only + sectionResponseRepo.findByResponseIdAndSectionId( + formResponse.getResponseId(), section.getSectionId()) + .ifPresent(sr -> { + questionResponseRepo.deleteBySectionResponseIdIn(Set.of(sr.getSectionResponseId())); + sectionResponseRepo.deleteById(sr.getSectionResponseId()); + }); + SectionResponse sectionResponse = upsertSectionResponse( formResponse.getResponseId(), section.getSectionId(), actor); diff --git a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java index 0bf48e62..94784d29 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java @@ -2,16 +2,17 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.iemr.flw.domain.iemr.DynamicForm; import com.iemr.flw.domain.iemr.TBConfirmedCaseDTO; import com.iemr.flw.domain.iemr.TBConfirmedCase; import com.iemr.flw.domain.iemr.TBStopVisit; -import com.iemr.flw.dto.iemr.TBConfirmedCasesResponseDTO; +import com.iemr.flw.repo.iemr.DynamicFormRepo; +import com.iemr.flw.repo.iemr.FormResponseRepo; import com.iemr.flw.repo.iemr.TBConfirmedTreatmentRepository; +import com.iemr.flw.seeder.TbCounsellingFormSeeder; import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.TBConfirmedCaseService; import com.iemr.flw.service.TBStopVisitService; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.iemr.flw.utils.JwtUtil; import com.iemr.flw.utils.LocalDateAdapter; import com.iemr.flw.utils.response.OutputResponse; @@ -21,9 +22,12 @@ import org.springframework.stereotype.Service; import java.time.LocalDate; -import java.util.HashMap; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -32,6 +36,8 @@ public class TBConfirmedCaseServiceImpl implements TBConfirmedCaseService { private static final Logger logger = LoggerFactory.getLogger(TBConfirmedCaseServiceImpl.class); private final TBConfirmedTreatmentRepository repository; + private final FormResponseRepo formResponseRepo; + private final DynamicFormRepo dynamicFormRepo; @Autowired private JwtUtil jwtUtil; @@ -41,8 +47,13 @@ public class TBConfirmedCaseServiceImpl implements TBConfirmedCaseService { @Autowired private TBStopVisitService tbStopVisitService; - public TBConfirmedCaseServiceImpl(TBConfirmedTreatmentRepository repository) { + + public TBConfirmedCaseServiceImpl(TBConfirmedTreatmentRepository repository, + FormResponseRepo formResponseRepo, + DynamicFormRepo dynamicFormRepo) { this.repository = repository; + this.formResponseRepo = formResponseRepo; + this.dynamicFormRepo = dynamicFormRepo; } @Override @@ -135,6 +146,18 @@ public String getByUserId(String authorisation) throws Exception { Integer userId = jwtUtil.extractUserId(authorisation); List list = repository.findByUserId(userId); List dtoList = list.stream().map(this::toDTO).collect(Collectors.toList()); + + List benIds = dtoList.stream().map(TBConfirmedCaseDTO::getBenId).collect(Collectors.toList()); + Set counselledBenIds = Collections.emptySet(); + if (!benIds.isEmpty()) { + Optional counsellingForm = dynamicFormRepo.findByFormUuid(TbCounsellingFormSeeder.FORM_UUID); + if (counsellingForm.isPresent()) { + counselledBenIds = new HashSet<>(formResponseRepo.findCounselledBenIds(benIds, counsellingForm.get().getFormId(), "COMPLETE")); + } + } + final Set counselled = counselledBenIds; + dtoList.forEach(dto -> dto.setCounselled(counselled.contains(dto.getBenId()))); + Map response = new java.util.HashMap<>(); response.put("userId", userId); response.put("tbConfirmedCases", dtoList); From 939690554eea878e467dcabc7a7ca66e52d497f4 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 23 Jun 2026 11:07:19 +0530 Subject: [PATCH 03/47] Align `/complete` and `/submitBulk` form response editing behavior * Updated `/complete` to resolve `FormResponse` using `beneficiaryId` and `formUuid` from the request body, removing the dependency on `responseId` as a query parameter. * Updated `/submitBulk` section editing to match `/complete` behavior by performing in-place `SectionResponse` upserts and per-question delete-and-reinsert operations, instead of deleting and recreating the entire section. --- .../DynamicFormResponseController.java | 3 +-- .../service/DynamicFormResponseService.java | 2 +- .../impl/DynamicFormResponseServiceImpl.java | 23 +++++++++++-------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java index 78a3dd83..3e190816 100644 --- a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java +++ b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java @@ -64,10 +64,9 @@ public ResponseEntity submitForm( @Operation(summary = "Save POST_SUBMIT section answers, status → COMPLETE") @RequestMapping(value = "/complete", method = RequestMethod.POST) public ResponseEntity completeForm( - @RequestParam Long responseId, @Valid @RequestBody FormResponseRequest request, @RequestHeader("JwtToken") String jwtToken) throws IEMRException { - FormResponseDTO dto = responseService.completeForm(responseId, request, jwtToken); + FormResponseDTO dto = responseService.completeForm(request, jwtToken); return ResponseEntity.ok(new ApiResponse(true, "Form completed successfully", dto)); } diff --git a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java index 7a07f23f..6b27ade6 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java @@ -38,7 +38,7 @@ public interface DynamicFormResponseService { FormResponseDTO submitForm(FormResponseRequest request); /** Save POST_SUBMIT section answers and advance status to COMPLETE. */ - FormResponseDTO completeForm(Long responseId, FormResponseRequest request, String jwtToken) throws IEMRException; + FormResponseDTO completeForm(FormResponseRequest request, String jwtToken) throws IEMRException; /** All responses for a beneficiary filtered by form UUID. */ List getResponsesByBeneficiary(Long beneficiaryId, String formUuid); diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index 4d1df037..b819afc2 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -115,23 +115,26 @@ public FormResponseDTO submitForm(FormResponseRequest request) { @Override @Transactional - public FormResponseDTO completeForm(Long responseId, FormResponseRequest request, String jwtToken) throws IEMRException { + public FormResponseDTO completeForm(FormResponseRequest request, String jwtToken) throws IEMRException { String actor = jwtUtil.extractUserId(jwtToken).toString(); - FormResponse formResponse = formResponseRepo.findById(responseId) - .orElseThrow(() -> new NoSuchElementException("FormResponse not found: " + responseId)); - if (!STATUS_SUBMITTED.equals(formResponse.getStatus())) { - throw new IllegalStateException( - "Cannot complete a response in status: " + formResponse.getStatus()); + + FormVersion version = resolveLatestVersion(request.getFormUuid()); + Long formId = version.getDynamicForm().getFormId(); + + List existing = + formResponseRepo.findByBeneficiaryIdAndFormId(request.getBeneficiaryId(), formId); + if (existing.isEmpty()) { + throw new NoSuchElementException( + "FormResponse not found for beneficiaryId=" + request.getBeneficiaryId() + + " formUuid=" + request.getFormUuid()); } + FormResponse formResponse = existing.get(0); + formResponse.setUpdatedBy(actor); formResponse.setStatus(STATUS_COMPLETE); formResponse.setCompletedAt(new Timestamp(System.currentTimeMillis())); formResponse = formResponseRepo.save(formResponse); - FormResponse finalFormResponse = formResponse; - FormVersion version = formVersionRepo.findById(formResponse.getVersionId()) - .orElseThrow(() -> new NoSuchElementException( - "FormVersion not found: " + finalFormResponse.getVersionId())); return processSections(formResponse, request, version, actor); } From 105ede6737c2c560136a42cad6a98a7ea23ecfd4 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Wed, 24 Jun 2026 14:47:52 +0530 Subject: [PATCH 04/47] Add van/sync tracking fields (vanID, parkingPlaceID, vanSerialNo, processed, syncedDate, syncedBy, syncFailureReason) to domain entities for offline mobile data collection support --- .../com/iemr/flw/domain/iemr/DynamicForm.java | 21 +++++++++++++++++ .../iemr/flw/domain/iemr/FormResponse.java | 21 +++++++++++++++++ .../com/iemr/flw/domain/iemr/FormSection.java | 22 ++++++++++++++++++ .../com/iemr/flw/domain/iemr/FormVersion.java | 21 +++++++++++++++++ .../iemr/flw/domain/iemr/OptionCondition.java | 23 +++++++++++++++++++ .../iemr/flw/domain/iemr/QuestionOption.java | 22 ++++++++++++++++++ .../flw/domain/iemr/QuestionResponse.java | 23 +++++++++++++++++++ .../flw/domain/iemr/QuestionValidation.java | 23 +++++++++++++++++++ .../iemr/flw/domain/iemr/SectionQuestion.java | 22 ++++++++++++++++++ .../iemr/flw/domain/iemr/SectionResponse.java | 21 +++++++++++++++++ 10 files changed, 219 insertions(+) diff --git a/src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java b/src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java index 5ff86faf..9d969b7d 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java @@ -94,6 +94,27 @@ public class DynamicForm { @ToString.Exclude private List versions = new ArrayList<>(); + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; + @PrePersist protected void onCreate() { createdAt = new Timestamp(System.currentTimeMillis()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormResponse.java b/src/main/java/com/iemr/flw/domain/iemr/FormResponse.java index 7c0a09e0..14e0b891 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormResponse.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormResponse.java @@ -99,6 +99,27 @@ public class FormResponse { @Column(name = "updatedAt", nullable = false) private Timestamp updatedAt; + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; + @PrePersist protected void onCreate() { createdAt = new Timestamp(System.currentTimeMillis()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java index 97f02763..d7078c06 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java @@ -37,6 +37,7 @@ import lombok.NoArgsConstructor; import lombok.ToString; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; @@ -90,4 +91,25 @@ public class FormSection { @OneToMany(mappedBy = "formSection", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @ToString.Exclude private List questions = new ArrayList<>(); + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java index 0e7ec4ae..437a29bc 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java @@ -87,6 +87,27 @@ public class FormVersion { @ToString.Exclude private List sections = new ArrayList<>(); + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; + @PrePersist protected void onCreate() { createdAt = new Timestamp(System.currentTimeMillis()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index ae54b9ac..281a87d0 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -35,6 +35,8 @@ import lombok.NoArgsConstructor; import lombok.ToString; +import java.sql.Timestamp; + /** * Conditional action triggered when a specific option is selected. * actionType: SHOW_QUESTION | DISABLE_SECTION_VALIDATION | LOCK_FORM @@ -77,4 +79,25 @@ public class OptionCondition { @Column(name = "updated_by", length = 100) private String updatedBy; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index f0145523..4d6cbe19 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -37,6 +37,7 @@ import lombok.NoArgsConstructor; import lombok.ToString; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; @@ -78,4 +79,25 @@ public class QuestionOption { @OneToMany(mappedBy = "questionOption", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @ToString.Exclude private List conditions = new ArrayList<>(); + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java index 23c62ddc..871e31b5 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java @@ -33,6 +33,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.sql.Timestamp; + /** * Stores one answer to a single question within a section response. * MCQ questions produce multiple rows (one per selected option) — no unique constraint on (sectionResponseId, questionId). @@ -76,4 +78,25 @@ public class QuestionResponse { @Column(name = "updated_by", length = 100) private String updatedBy; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java index eb533442..b3253526 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java @@ -38,6 +38,8 @@ import lombok.NoArgsConstructor; import lombok.ToString; +import java.sql.Timestamp; + /** * Server-side validation rule for a question. * validationType: MAX_LENGTH | MIN_DATE | MAX_DATE | REGEX | MANDATORY_IF @@ -75,4 +77,25 @@ public class QuestionValidation { @Column(name = "updated_by", length = 100) private String updatedBy; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 1e8581e7..0b571ea2 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -41,6 +41,7 @@ import lombok.NoArgsConstructor; import lombok.ToString; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; @@ -107,4 +108,25 @@ public class SectionQuestion { @OneToMany(mappedBy = "sectionQuestion", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @ToString.Exclude private List validations = new ArrayList<>(); + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java b/src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java index 09fbae82..1fded4c8 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java @@ -71,4 +71,25 @@ public class SectionResponse { @Column(name = "updated_by", length = 100) private String updatedBy; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; + + @Column(name = "Syncedby", length = 50) + private String syncedBy; + + @Column(name = "SyncFailureReason", length = 255) + private String syncFailureReason; } From 21b71877b48b4aa7870cee265e0c24613471111f Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Thu, 25 Jun 2026 14:56:15 +0530 Subject: [PATCH 05/47] feat(dynamic-form): add Hindi localization fields and completed beneficiaries endpoint - Added `sectionName_hindi`, `questionText_hindi`, and `optionValue_hindi` fields to the FormSection, SectionQuestion, and QuestionOption entities to support Hindi localization. - Added `GET /dynamicForm/response/getCompletedBeneficiaries?formType=` endpoint to return beneficiary IDs with `COMPLETE` status for the specified form type. --- .../DynamicFormResponseController.java | 7 ++ .../com/iemr/flw/domain/iemr/FormSection.java | 3 + .../iemr/flw/domain/iemr/QuestionOption.java | 6 ++ .../iemr/flw/domain/iemr/SectionQuestion.java | 3 + .../com/iemr/flw/dto/iemr/FormSectionDTO.java | 2 + .../iemr/flw/dto/iemr/QuestionOptionDTO.java | 4 + .../iemr/flw/dto/iemr/SectionQuestionDTO.java | 2 + .../iemr/flw/repo/iemr/FormResponseRepo.java | 3 +- .../flw/seeder/TbCounsellingFormSeeder.java | 94 ++++++++++--------- .../service/DynamicFormResponseService.java | 4 + .../impl/DynamicFormResponseServiceImpl.java | 13 +++ 11 files changed, 96 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java index 1261dcf2..65dd7036 100644 --- a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java +++ b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java @@ -23,6 +23,7 @@ import com.iemr.flw.dto.iemr.FormResponseDTO; import com.iemr.flw.dto.iemr.FormResponseRequest; +import com.iemr.flw.masterEnum.FormType; import com.iemr.flw.service.DynamicFormResponseService; import com.iemr.flw.utils.ApiResponse; import com.iemr.flw.utils.exception.IEMRException; @@ -97,4 +98,10 @@ public ResponseEntity getById(@RequestParam Long responseId) { FormResponseDTO dto = responseService.getResponseById(responseId); return ResponseEntity.ok(new ApiResponse(true, "Response fetched successfully", dto)); } + @Operation(summary = "Get beneficiary IDs that have a COMPLETE form response for the given form type") + @RequestMapping(value = "/getCompletedBeneficiaries", method = RequestMethod.GET) + public ResponseEntity getCompletedBeneficiaries(@RequestParam FormType formType) { + List beneficiaryIds = responseService.getCompletedBeneficiaries(formType); + return ResponseEntity.ok(new ApiResponse(true, "Completed beneficiaries fetched successfully", beneficiaryIds)); + } } diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java index d7078c06..8b505ec8 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java @@ -68,6 +68,9 @@ public class FormSection { @Column(name = "sectionName", nullable = false, length = 255) private String sectionName; + @Column(name = "sectionName_hindi", length = 255) + private String sectionNameHindi; + /** PRE_SUBMIT sections are shown before the form's main submit. POST_SUBMIT after. */ @Column(name = "sectionPhase", nullable = false, length = 20) private String sectionPhase; diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index 4d6cbe19..192af9ac 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -64,9 +64,15 @@ public class QuestionOption { @Column(name = "optionLabel", nullable = false, length = 255) private String optionLabel; + @Column(name = "optionLabel_hindi", length = 100) + private String optionLabelHindi; + @Column(name = "optionValue", nullable = false, length = 100) private String optionValue; + @Column(name = "optionValue_hindi", length = 100) + private String optionValueHindi; + @Column(name = "displayOrder", nullable = false) private Integer displayOrder; diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 0b571ea2..5b13d733 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -73,6 +73,9 @@ public class SectionQuestion { @Column(name = "questionText", nullable = false) private String questionText; + @Column(name = "questionText_hindi", columnDefinition = "TEXT") + private String questionTextHindi; + @Enumerated(EnumType.STRING) @Column(name = "questionType", nullable = false, length = 20) private QuestionType questionType; diff --git a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java index 4c537106..2db1f8c7 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java @@ -47,6 +47,8 @@ public class FormSectionDTO { @NotBlank(message = "sectionName is required") private String sectionName; + private String sectionNameHindi; + @NotBlank(message = "sectionPhase is required (PRE_SUBMIT or POST_SUBMIT)") private String sectionPhase; diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java index bd78c6b4..0328263c 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java @@ -44,9 +44,13 @@ public class QuestionOptionDTO { @NotBlank(message = "optionLabel is required") private String optionLabel; + private String optionLabelHindi; + @NotBlank(message = "optionValue is required") private String optionValue; + private String optionValueHindi; + @NotNull(message = "displayOrder is required") private Integer displayOrder; diff --git a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java index e19bbeb0..11a81430 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java @@ -49,6 +49,8 @@ public class SectionQuestionDTO { @NotBlank(message = "questionText is required") private String questionText; + private String questionTextHindi; + @NotNull(message = "questionType is required") private QuestionType questionType; diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java index 830cad08..02077c9f 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java @@ -40,7 +40,8 @@ public interface FormResponseRepo extends JpaRepository { List findByBeneficiaryIdAndFormId(Long beneficiaryId, Long formId); - List findByBeneficiaryId(Long beneficiaryId); + @Query("SELECT r.beneficiaryId FROM FormResponse r WHERE r.formId = :formId AND r.status = :status") + List findBeneficiaryIdsByFormIdAndStatus(@Param("formId") Long formId, @Param("status") String status); @Query("SELECT r.beneficiaryId FROM FormResponse r " + "WHERE r.beneficiaryId IN :benIds " + diff --git a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java index 6c0a248c..edb26b0f 100644 --- a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java +++ b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java @@ -33,6 +33,7 @@ import com.iemr.flw.repo.iemr.DynamicFormRepo; import com.iemr.flw.service.DynamicFormDefinitionService; import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -47,6 +48,7 @@ * @author Piramal Swasthya */ @Component +@RequiredArgsConstructor public class TbCounsellingFormSeeder { private static final Logger log = LoggerFactory.getLogger(TbCounsellingFormSeeder.class); @@ -56,12 +58,6 @@ public class TbCounsellingFormSeeder { private final DynamicFormDefinitionService formService; private final DynamicFormRepo formRepo; - public TbCounsellingFormSeeder(DynamicFormDefinitionService formService, - DynamicFormRepo formRepo) { - this.formService = formService; - this.formRepo = formRepo; - } - @PostConstruct public void seed() { if (formRepo.findByFormUuid(FORM_UUID).isPresent()) { @@ -97,13 +93,13 @@ private DynamicFormDTO buildFormDto() { // ── Section A: Disease Awareness ───────────────────────────────────────────── private FormSectionDTO buildSectionA() { - FormSectionDTO s = section("TB_SEC_A", "Disease Awareness", "PRE_SUBMIT", 1, true, false); + FormSectionDTO s = section("TB_SEC_A", "Disease Awareness", "बीमारी के बारे में जागरूकता", "PRE_SUBMIT", 1, true, false); List qs = new ArrayList<>(); - qs.add(yesNoRadio("TB_A_Q1", "TB disease explained to patient", 1, true)); - qs.add(yesNoRadio("TB_A_Q2", "Transmission route explained", 2, true)); - qs.add(yesNoRadio("TB_A_Q3", "Symptoms explained", 3, true)); - qs.add(yesNoRadio("TB_A_Q4", "Treatment duration explained", 4, true)); - qs.add(textQuestion("TB_A_REMARKS", "Disease awareness notes", 5, false, 500, true)); + qs.add(yesNoRadio("TB_A_Q1", "TB disease explained to patient", "मरीज़ को टीबी बीमारी के बारे में समझाया गया।", 1, true)); + qs.add(yesNoRadio("TB_A_Q2", "Transmission route explained", "ट्रांसमिशन के तरीके के बारे में जानकारी", 2, true)); + qs.add(yesNoRadio("TB_A_Q3", "Symptoms explained", "लक्षणों की जानकारी", 3, true)); + qs.add(yesNoRadio("TB_A_Q4", "Treatment duration explained", "इलाज की अवधि के बारे में जानकारी", 4, true)); + qs.add(textQuestion("TB_A_REMARKS", "Disease awareness notes", "बीमारी के बारे में जानकारी देने वाले नोट्स", 5, false, 500, true)); s.setQuestions(qs); return s; } @@ -111,15 +107,15 @@ private FormSectionDTO buildSectionA() { // ── Section B: Do's and Don'ts ─────────────────────────────────────────────── private FormSectionDTO buildSectionB() { - FormSectionDTO s = section("TB_SEC_B", "Do's and Don'ts", "PRE_SUBMIT", 2, true, false); + FormSectionDTO s = section("TB_SEC_B", "Do's and Don'ts", "करो और ना करो", "PRE_SUBMIT", 2, true, false); List qs = new ArrayList<>(); - qs.add(yesNoRadio("TB_B_Q1", "Cover mouth while coughing — advised", 1, true)); - qs.add(yesNoRadio("TB_B_Q2", "Complete full treatment course — advised", 2, true)); - qs.add(yesNoRadio("TB_B_Q3", "Regular follow-up attendance — advised", 3, true)); - qs.add(yesNoRadio("TB_B_Q4", "Nutritional guidance provided", 4, true)); - qs.add(yesNoRadio("TB_B_Q5", "No smoking / alcohol — advised", 5, true)); - qs.add(yesNoRadio("TB_B_Q6", "Isolation precautions explained", 6, true)); - qs.add(textQuestion("TB_B_REMARKS", "Do's & Don'ts notes", 7, false, 500, true)); + qs.add(yesNoRadio("TB_B_Q1", "Cover mouth while coughing — advised", "खांसते समय मुंह ढकने की सलाह दी जाती है।", 1, true)); + qs.add(yesNoRadio("TB_B_Q2", "Complete full treatment course — advised", "इलाज का पूरा कोर्स पूरा करने की सलाह दी जाती है।", 2, true)); + qs.add(yesNoRadio("TB_B_Q3", "Regular follow-up attendance — advised", "इलाज का पूरा कोर्स पूरा करने की सलाह दी जाती है।", 3, true)); + qs.add(yesNoRadio("TB_B_Q4", "Nutritional guidance provided", "पोषण संबंधी मार्गदर्शन प्रदान किया गया", 4, true)); + qs.add(yesNoRadio("TB_B_Q5", "No smoking / alcohol — advised", "धूम्रपान / शराब न लेने की सलाह दी जाती है।", 5, true)); + qs.add(yesNoRadio("TB_B_Q6", "Isolation precautions explained", "अलगाव सावधानियों के बारे में बताया गया", 6, true)); + qs.add(textQuestion("TB_B_REMARKS", "Do's & Don'ts notes", "क्या करें और क्या न करें - नोट्स", 7, false, 500, true)); s.setQuestions(qs); return s; } @@ -127,11 +123,11 @@ private FormSectionDTO buildSectionB() { // ── Section C: Government Schemes ──────────────────────────────────────────── private FormSectionDTO buildSectionC() { - FormSectionDTO s = section("TB_SEC_C", "Government Schemes", "PRE_SUBMIT", 3, false, false); + FormSectionDTO s = section("TB_SEC_C", "Government Schemes", "सरकारी योजनाएं", "PRE_SUBMIT", 3, false, false); List qs = new ArrayList<>(); - qs.add(yesNoRadio("TB_C_Q1", "Nikshay Poshan Yojana (NPY) eligibility explained", 1, false)); - qs.add(yesNoRadio("TB_C_Q2", "DOTS free treatment explained", 2, false)); - qs.add(textQuestion("TB_C_REMARKS", "Schemes notes", 3, false, 300, true)); + qs.add(yesNoRadio("TB_C_Q1", "Nikshay Poshan Yojana (NPY) eligibility explained", "निक्षय पोषण योजना (एनपीवाई) पात्रता के बारे में बताया गया", 1, false)); + qs.add(yesNoRadio("TB_C_Q2", "DOTS free treatment explained", "DOTS मुफ़्त इलाज के बारे में जानकारी", 2, false)); + qs.add(textQuestion("TB_C_REMARKS", "Schemes notes", "योजनाओं के नोट्स", 3, false, 300, true)); s.setQuestions(qs); return s; } @@ -139,13 +135,13 @@ private FormSectionDTO buildSectionC() { // ── Section D: Treatment Regimen ───────────────────────────────────────────── private FormSectionDTO buildSectionD() { - FormSectionDTO s = section("TB_SEC_D", "Treatment Regimen", "PRE_SUBMIT", 4, true, false); + FormSectionDTO s = section("TB_SEC_D", "Treatment Regimen", "इलाज का तरीका", "PRE_SUBMIT", 4, true, false); List qs = new ArrayList<>(); - qs.add(yesNoRadio("TB_D_Q1", "Regimen explained to patient", 1, true)); - qs.add(yesNoRadio("TB_D_Q2", "Medication names explained", 2, true)); - qs.add(yesNoRadio("TB_D_Q3", "Side effects explained", 3, true)); - qs.add(yesNoRadio("TB_D_Q4", "Importance of adherence explained", 4, true)); - qs.add(textQuestion("TB_D_REMARKS", "Treatment regimen notes", 5, false, 300, true)); + qs.add(yesNoRadio("TB_D_Q1", "Regimen explained to patient", "मरीज़ को इलाज का तरीका समझाया गया।", 1, true)); + qs.add(yesNoRadio("TB_D_Q2", "Medication names explained", "दवाओं के नामों की जानकारी", 2, true)); + qs.add(yesNoRadio("TB_D_Q3", "Side effects explained", "साइड इफ़ेक्ट्स के बारे में जानकारी", 3, true)); + qs.add(yesNoRadio("TB_D_Q4", "Importance of adherence explained", "निर्देशों का पालन करने का महत्व समझाया गया", 4, true)); + qs.add(textQuestion("TB_D_REMARKS", "Treatment regimen notes", "इलाज के तरीके से जुड़े नोट्स", 5, false, 300, true)); s.setQuestions(qs); return s; } @@ -153,20 +149,21 @@ private FormSectionDTO buildSectionD() { // ── Section E: Counselling Completion ──────────────────────────────────────── private FormSectionDTO buildSectionE() { - FormSectionDTO s = section("TB_SEC_E", "Counselling Completion", "PRE_SUBMIT", 5, true, true); + FormSectionDTO s = section("TB_SEC_E", "Counselling Completion", "काउंसलिंग पूरी होना", "PRE_SUBMIT", 5, true, true); List qs = new ArrayList<>(); // Counselling completion status — RADIO: Complete | Refused SectionQuestionDTO statusQ = new SectionQuestionDTO(); statusQ.setQuestionUuid("TB_E_Q1"); statusQ.setQuestionText("Counselling completion status"); + statusQ.setQuestionTextHindi("काउंसलिंग पूरी होने की स्थिति"); statusQ.setQuestionType(QuestionType.RADIO); statusQ.setIsMandatory(true); statusQ.setDisplayOrder(1); statusQ.setVisibleByDefault(true); // "Complete" option — no conditions - QuestionOptionDTO completeOpt = option("Complete", "COMPLETE", 1, List.of()); + QuestionOptionDTO completeOpt = option("Complete", "सम्पूर्ण","COMPLETE", "सम्पूर्ण", 1, List.of()); // "Refused" option — 5 conditions: disable sections A-D + show refusal text box List refusedConditions = new ArrayList<>(); @@ -175,7 +172,7 @@ private FormSectionDTO buildSectionE() { refusedConditions.add(disableSectionValidation("TB_SEC_C")); refusedConditions.add(disableSectionValidation("TB_SEC_D")); refusedConditions.add(showQuestion("TB_E_REFUSAL")); - QuestionOptionDTO refusedOpt = option("Refused", "REFUSED", 2, refusedConditions); + QuestionOptionDTO refusedOpt = option("Refused", "अस्वीकार करना", "REFUSED", "अस्वीकार करना", 2, refusedConditions); List statusOptions = new ArrayList<>(); statusOptions.add(completeOpt); @@ -185,10 +182,10 @@ private FormSectionDTO buildSectionE() { qs.add(statusQ); // Reason for refusal — hidden by default, max 300 chars - qs.add(textQuestion("TB_E_REFUSAL", "Reason for refusal", 2, true, 300, false)); + qs.add(textQuestion("TB_E_REFUSAL", "Reason for refusal", "इनकार का कारण",2, true, 300, false)); // Counsellor remarks — optional, max 500 chars - qs.add(textQuestion("TB_E_REMARKS", "Counsellor remarks", 3, false, 500, true)); + qs.add(textQuestion("TB_E_REMARKS", "Counsellor remarks", "काउंसलर की टिप्पणी", 3, false, 500, true)); s.setQuestions(qs); return s; @@ -197,23 +194,24 @@ private FormSectionDTO buildSectionE() { // ── Section F: Follow Up to TU ─────────────────────────────────────────────── private FormSectionDTO buildSectionF() { - FormSectionDTO s = section("TB_SEC_F", "Follow Up to TU", "POST_SUBMIT", 6, true, true); + FormSectionDTO s = section("TB_SEC_F", "Follow Up to TU", "TU के बाद की कार्रवाई", "POST_SUBMIT", 6, true, true); List qs = new ArrayList<>(); // "Has the patient started TB treatment?" — RADIO: Yes | No SectionQuestionDTO startedQ = new SectionQuestionDTO(); startedQ.setQuestionUuid("TB_F_Q1"); startedQ.setQuestionText("Has the patient started the prescribed TB treatment regimen?"); + startedQ.setQuestionTextHindi("क्या मरीज़ ने टीबी के लिए बताया गया इलाज शुरू कर दिया है?"); startedQ.setQuestionType(QuestionType.RADIO); startedQ.setIsMandatory(true); startedQ.setDisplayOrder(1); startedQ.setVisibleByDefault(true); - QuestionOptionDTO yesOpt = option("Yes", "YES", 1, List.of()); + QuestionOptionDTO yesOpt = option("Yes", "हाँ", "YES", "हाँ", 1, List.of()); List noConditions = new ArrayList<>(); noConditions.add(showQuestion("TB_F_NO_REASON")); - QuestionOptionDTO noOpt = option("No", "NO", 2, noConditions); + QuestionOptionDTO noOpt = option("No", "नहीं", "NO", "नहीं", 2, noConditions); List startedOptions = new ArrayList<>(); startedOptions.add(yesOpt); @@ -225,16 +223,19 @@ private FormSectionDTO buildSectionF() { // Reason for not starting — hidden by default, max 500 chars qs.add(textQuestion("TB_F_NO_REASON", "Reason for not starting the prescribed TB treatment regimen", + "टीबी के लिए तय इलाज का तरीका शुरू न करने का कारण", 2, false, 500, false)); // DOTS centre visit qs.add(yesNoRadio("TB_F_Q2", "Has the patient visited the DOTS centre / referred health facility for treatment collection?", + "क्या मरीज़ इलाज लेने के लिए DOTS सेंटर या रेफर किए गए स्वास्थ्य केंद्र गया है?", 3, true)); // Side effects reported qs.add(yesNoRadio("TB_F_Q3", "Has the patient reported side effects to the treating doctor or DOTS centre?", + "क्या मरीज़ ने इलाज करने वाले डॉक्टर या DOTS सेंटर को साइड इफ़ेक्ट्स के बारे में बताया है?", 4, true)); s.setQuestions(qs); @@ -243,11 +244,12 @@ private FormSectionDTO buildSectionF() { // ── Builder Helpers ─────────────────────────────────────────────────────────── - private FormSectionDTO section(String uuid, String name, String phase, + private FormSectionDTO section(String uuid, String name, String nameHindi, String phase, int order, boolean required, boolean hasSubmitButton) { FormSectionDTO s = new FormSectionDTO(); s.setSectionUuid(uuid); s.setSectionName(name); + s.setSectionNameHindi(nameHindi); s.setSectionPhase(phase); s.setDisplayOrder(order); s.setIsRequired(required); @@ -256,28 +258,30 @@ private FormSectionDTO section(String uuid, String name, String phase, return s; } - private SectionQuestionDTO yesNoRadio(String uuid, String text, int order, boolean mandatory) { + private SectionQuestionDTO yesNoRadio(String uuid, String text, String textHindi, int order, boolean mandatory) { SectionQuestionDTO q = new SectionQuestionDTO(); q.setQuestionUuid(uuid); q.setQuestionText(text); + q.setQuestionTextHindi(textHindi); q.setQuestionType(QuestionType.RADIO); q.setIsMandatory(mandatory); q.setDisplayOrder(order); q.setVisibleByDefault(true); List opts = new ArrayList<>(); - opts.add(option("Yes", "YES", 1, List.of())); - opts.add(option("No", "NO", 2, List.of())); + opts.add(option("Yes", "हाँ","YES","हाँ", 1, List.of())); + opts.add(option("No", "नहीं","NO", "नहीं", 2, List.of())); q.setOptions(opts); q.setValidations(List.of()); return q; } - private SectionQuestionDTO textQuestion(String uuid, String text, int order, + private SectionQuestionDTO textQuestion(String uuid, String text, String textHindi, int order, boolean mandatory, int maxLength, boolean visible) { SectionQuestionDTO q = new SectionQuestionDTO(); q.setQuestionUuid(uuid); q.setQuestionText(text); + q.setQuestionTextHindi(textHindi); q.setQuestionType(QuestionType.TEXT); q.setIsMandatory(mandatory); q.setDisplayOrder(order); @@ -293,11 +297,13 @@ private SectionQuestionDTO textQuestion(String uuid, String text, int order, return q; } - private QuestionOptionDTO option(String label, String value, int order, + private QuestionOptionDTO option(String label, String labelHindi, String value, String valueHindi, int order, List conditions) { QuestionOptionDTO o = new QuestionOptionDTO(); o.setOptionLabel(label); + o.setOptionLabelHindi(labelHindi); o.setOptionValue(value); + o.setOptionValueHindi(valueHindi); o.setDisplayOrder(order); o.setConditions(conditions); return o; diff --git a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java index 6b27ade6..436cae75 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java @@ -23,6 +23,7 @@ import com.iemr.flw.dto.iemr.FormResponseDTO; import com.iemr.flw.dto.iemr.FormResponseRequest; +import com.iemr.flw.masterEnum.FormType; import com.iemr.flw.utils.exception.IEMRException; import java.util.List; @@ -59,4 +60,7 @@ public interface DynamicFormResponseService { * Used by the follow-up notification scheduler. */ List findPendingFollowUps(List formIds, int delayDays); + + /** Returns beneficiary IDs with COMPLETE status for the given form type. */ + List getCompletedBeneficiaries(FormType formType); } diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index a4d13079..8c8cc6ea 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -34,6 +34,9 @@ import com.iemr.flw.dto.iemr.QuestionResponseDTO; import com.iemr.flw.dto.iemr.SectionAnswerRequest; import com.iemr.flw.dto.iemr.SectionResponseDTO; +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.masterEnum.FormType; +import com.iemr.flw.repo.iemr.DynamicFormRepo; import com.iemr.flw.repo.iemr.FormResponseRepo; import com.iemr.flw.repo.iemr.FormSectionRepo; import com.iemr.flw.repo.iemr.FormVersionRepo; @@ -77,6 +80,7 @@ public class DynamicFormResponseServiceImpl implements DynamicFormResponseServic private static final String STATUS_COMPLETE = "COMPLETE"; private static final String SECTION_STATUS_DONE = "DONE"; + private final DynamicFormRepo dynamicFormRepo; private final FormResponseRepo formResponseRepo; private final SectionResponseRepo sectionResponseRepo; private final QuestionResponseRepo questionResponseRepo; @@ -462,4 +466,13 @@ public List findPendingFollowUps(List formIds, int delayD .map(r -> buildFormResponseDTO(r, List.of())) .collect(Collectors.toList()); } + + @Override + @Transactional(readOnly = true) + public List getCompletedBeneficiaries(FormType formType) { + DynamicForm form = dynamicFormRepo.findByFormTypeAndIsActive(formType, true) + .orElseThrow(() -> new NoSuchElementException( + "No active form found for type: " + formType)); + return formResponseRepo.findBeneficiaryIdsByFormIdAndStatus(form.getFormId(), STATUS_COMPLETE); + } } From fdb1a1f25352b19e05d731cf7d2afd52015b36c6 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Thu, 25 Jun 2026 14:56:15 +0530 Subject: [PATCH 06/47] feat(dynamic-form): add Hindi localization fields and completed beneficiaries endpoint - Added `sectionName_hindi`, `questionText_hindi`, and `optionValue_hindi` fields to the FormSection, SectionQuestion, and QuestionOption entities to support Hindi localization. - Added `GET /dynamicForm/response/getCompletedBeneficiaries?formType=` endpoint to return beneficiary IDs with `COMPLETE` status for the specified form type. --- src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/FormResponse.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/FormSection.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/FormVersion.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java | 2 +- src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java b/src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java index 9d969b7d..af183d66 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DynamicForm.java @@ -112,7 +112,7 @@ public class DynamicForm { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; @PrePersist diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormResponse.java b/src/main/java/com/iemr/flw/domain/iemr/FormResponse.java index 14e0b891..7462a561 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormResponse.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormResponse.java @@ -117,7 +117,7 @@ public class FormResponse { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; @PrePersist diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java index 8b505ec8..b8454716 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java @@ -113,6 +113,6 @@ public class FormSection { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java index 437a29bc..5902aa21 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java @@ -105,7 +105,7 @@ public class FormVersion { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; @PrePersist diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index 192af9ac..2bc02cbd 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -104,6 +104,6 @@ public class QuestionOption { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java index 871e31b5..c01fc715 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionResponse.java @@ -97,6 +97,6 @@ public class QuestionResponse { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java index b3253526..b7d41dc1 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java @@ -96,6 +96,6 @@ public class QuestionValidation { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 5b13d733..1bd7991b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -130,6 +130,6 @@ public class SectionQuestion { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java b/src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java index 1fded4c8..36996028 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionResponse.java @@ -90,6 +90,6 @@ public class SectionResponse { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } From 5f05f6615d1fbec7046e92395dba6083de61f666 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 26 Jun 2026 13:20:35 +0530 Subject: [PATCH 07/47] feat(beneficiary): expose GPS coordinates in getBeneficiaryData response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add gpsLatitude, gpsLongitude, digipin, gpsTimestamp, and isGpsUnavailable fields to RMNCHBeneficiaryDetailsRmnch and RMNCHHouseHoldDetails entities, mapping to the columns written by Identity-API's /syncDataToAmrit endpoint. Enable serializeNulls on the Gson builder in BeneficiaryServiceImpl so GPS keys are always present in householdDetails and beneficiaryDetails — null for web-registered records, real values for mobile-registered ones. Co-Authored-By: Claude Sonnet 4.6 --- .../RMNCHBeneficiaryDetailsRmnch.java | 18 +++++++++++++++++ .../identity/RMNCHHouseHoldDetails.java | 8 ++++++++ .../service/impl/BeneficiaryServiceImpl.java | 20 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java index e4b634ff..84827388 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java @@ -102,6 +102,24 @@ public class RMNCHBeneficiaryDetailsRmnch { @Column(name = "longitude") private BigDecimal longitude; + @Column(name = "gpsLatitude") + private Double gpsLatitude; + + @Column(name = "gpsLongitude") + private Double gpsLongitude; + + @Expose + @Column(name = "digipin") + private String digipin; + + @Expose + @Column(name = "gpsTimestamp") + private Timestamp gpsTimestamp; + + @Expose + @Column(name = "isGpsUnavailable", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private Boolean isGpsUnavailable = false; + @Expose @Column(name = "menstrualBFDId") private Integer menstrualBFDId; diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java index 3a3ccec8..a0a0adf1 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java @@ -329,4 +329,12 @@ public class RMNCHHouseHoldDetails { @Column(name = "isDeactivate") private Boolean isDeactivate; + @Expose + @Column(name = "gpsLatitude") + private Double gpsLatitude; + + @Expose + @Column(name = "gpsLongitude") + private Double gpsLongitude; + } diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index bbcf59a1..dd1af6f5 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -1,5 +1,6 @@ package com.iemr.flw.service.impl; +import java.math.BigDecimal; import java.math.BigInteger; import java.sql.Date; import java.text.SimpleDateFormat; @@ -324,6 +325,25 @@ private String getMappingsForAddressIDs(List addressLi if (benAddressOBJ.getPermAddrLine3() != null) benDetailsRMNCH_OBJ.setAddressLine3(benAddressOBJ.getPermAddrLine3()); + // GPS fallback: if not in RMNCH details (syncdatatoamrti not yet called), + // pull from i_beneficiaryaddress (saved during TM-API registration) + if (benDetailsRMNCH_OBJ.getGpsLatitude() == null && benAddressOBJ.getGpsLatitude() != null) + benDetailsRMNCH_OBJ.setGpsLatitude(benAddressOBJ.getGpsLatitude()); + if (benDetailsRMNCH_OBJ.getGpsLongitude() == null && benAddressOBJ.getGpsLongitude() != null) + benDetailsRMNCH_OBJ.setGpsLongitude(benAddressOBJ.getGpsLongitude()); + if (benDetailsRMNCH_OBJ.getDigipin() == null && benAddressOBJ.getDigipin() != null) + benDetailsRMNCH_OBJ.setDigipin(benAddressOBJ.getDigipin()); + if (benDetailsRMNCH_OBJ.getGpsTimestamp() == null && benAddressOBJ.getGpsTimestamp() != null) + benDetailsRMNCH_OBJ.setGpsTimestamp(benAddressOBJ.getGpsTimestamp()); + if (benDetailsRMNCH_OBJ.getIsGpsUnavailable() == null && benAddressOBJ.getIsGpsUnavailable() != null) + benDetailsRMNCH_OBJ.setIsGpsUnavailable(benAddressOBJ.getIsGpsUnavailable()); + + // Map GPS double fields to the exposed latitude/longitude BigDecimal fields for response + if (benDetailsRMNCH_OBJ.getGpsLatitude() != null) + benDetailsRMNCH_OBJ.setLatitude(BigDecimal.valueOf(benDetailsRMNCH_OBJ.getGpsLatitude())); + if (benDetailsRMNCH_OBJ.getGpsLongitude() != null) + benDetailsRMNCH_OBJ.setLongitude(BigDecimal.valueOf(benDetailsRMNCH_OBJ.getGpsLongitude())); + // ----------------------------------------------------------------------------- // related benids From 6ae9ed0d0e9d0771bf3f8be788893fbaa18cfd27 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 26 Jun 2026 13:21:21 +0530 Subject: [PATCH 08/47] =?UTF-8?q?Committed=2082b00ccf=20with=20only=20the?= =?UTF-8?q?=20three=20files:=20=20=20-=20BeneficiaryServiceImpl.java=20?= =?UTF-8?q?=E2=80=94=20serializeNulls()=20+=20GPS=20fallback=20from=20addr?= =?UTF-8?q?ess=20=20=20-=20RMNCHBeneficiaryDetailsRmnch.java=20=E2=80=94?= =?UTF-8?q?=20gpsLatitude,=20gpsLongitude,=20digipin,=20gpsTimestamp,=20is?= =?UTF-8?q?GpsUnavailable=20fields=20=20=20-=20RMNCHHouseHoldDetails.java?= =?UTF-8?q?=20=E2=80=94=20gpsLatitude,=20gpsLongitude=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/identity/RMNCHMBeneficiaryaddress.java | 15 +++++++++++++++ .../com/iemr/flw/domain/iemr/OptionCondition.java | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java index c9468688..ea58143d 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java @@ -247,4 +247,19 @@ public class RMNCHMBeneficiaryaddress implements Serializable { @Expose @Column(name = "SyncedDate") private Timestamp SyncedDate; + + @Column(name = "gpsLatitude") + private Double gpsLatitude; + + @Column(name = "gpsLongitude") + private Double gpsLongitude; + + @Column(name = "digipin") + private String digipin; + + @Column(name = "gpsTimestamp") + private Timestamp gpsTimestamp; + + @Column(name = "isGpsUnavailable", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private Boolean isGpsUnavailable = false; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index 281a87d0..50af2b38 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -98,6 +98,6 @@ public class OptionCondition { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } From 0332e9b9641db358a4100552e09002589d925792 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 26 Jun 2026 13:23:03 +0530 Subject: [PATCH 09/47] Revert "Committed 82b00ccf with only the three files:" This reverts commit 3040a08077d82d27dfd86f29f0fbdd4dc094ec56. --- .../domain/identity/RMNCHMBeneficiaryaddress.java | 15 --------------- .../com/iemr/flw/domain/iemr/OptionCondition.java | 2 +- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java index ea58143d..c9468688 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java @@ -247,19 +247,4 @@ public class RMNCHMBeneficiaryaddress implements Serializable { @Expose @Column(name = "SyncedDate") private Timestamp SyncedDate; - - @Column(name = "gpsLatitude") - private Double gpsLatitude; - - @Column(name = "gpsLongitude") - private Double gpsLongitude; - - @Column(name = "digipin") - private String digipin; - - @Column(name = "gpsTimestamp") - private Timestamp gpsTimestamp; - - @Column(name = "isGpsUnavailable", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") - private Boolean isGpsUnavailable = false; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index 50af2b38..281a87d0 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -98,6 +98,6 @@ public class OptionCondition { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason") + @Column(name = "SyncFailureReason", length = 255) private String syncFailureReason; } From 412398551a30e444619521c0ffe82475a25d7844 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 26 Jun 2026 13:30:30 +0530 Subject: [PATCH 10/47] =?UTF-8?q?Committed=2082b00ccf=20with=20only=20the?= =?UTF-8?q?=20three=20files:=20=20=20-=20BeneficiaryServiceImpl.java=20?= =?UTF-8?q?=E2=80=94=20serializeNulls()=20+=20GPS=20fallback=20from=20addr?= =?UTF-8?q?ess=20=20=20-=20RMNCHBeneficiaryDetailsRmnch.java=20=E2=80=94?= =?UTF-8?q?=20gpsLatitude,=20gpsLongitude,=20digipin,=20gpsTimestamp,=20is?= =?UTF-8?q?GpsUnavailable=20fields=20=20=20-=20RMNCHHouseHoldDetails.java?= =?UTF-8?q?=20=E2=80=94=20gpsLatitude,=20gpsLongitude=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/identity/RMNCHMBeneficiaryaddress.java | 15 +++++++++++++++ .../com/iemr/flw/domain/iemr/OptionCondition.java | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java index c9468688..ea58143d 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java @@ -247,4 +247,19 @@ public class RMNCHMBeneficiaryaddress implements Serializable { @Expose @Column(name = "SyncedDate") private Timestamp SyncedDate; + + @Column(name = "gpsLatitude") + private Double gpsLatitude; + + @Column(name = "gpsLongitude") + private Double gpsLongitude; + + @Column(name = "digipin") + private String digipin; + + @Column(name = "gpsTimestamp") + private Timestamp gpsTimestamp; + + @Column(name = "isGpsUnavailable", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private Boolean isGpsUnavailable = false; } diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index 281a87d0..50af2b38 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -98,6 +98,6 @@ public class OptionCondition { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } From b7488fec8a87216dd9dc7c9ad0b261048a8c0fee Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 26 Jun 2026 17:01:29 +0530 Subject: [PATCH 11/47] =?UTF-8?q?Committed=2082b00ccf=20with=20only=20the?= =?UTF-8?q?=20three=20files:=20=20=20-=20BeneficiaryServiceImpl.java=20?= =?UTF-8?q?=E2=80=94=20serializeNulls()=20+=20GPS=20fallback=20from=20addr?= =?UTF-8?q?ess=20=20=20-=20RMNCHBeneficiaryDetailsRmnch.java=20=E2=80=94?= =?UTF-8?q?=20gpsLatitude,=20gpsLongitude,=20digipin,=20gpsTimestamp,=20is?= =?UTF-8?q?GpsUnavailable=20fields=20=20=20-=20RMNCHHouseHoldDetails.java?= =?UTF-8?q?=20=E2=80=94=20gpsLatitude,=20gpsLongitude=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java --- .../flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java | 4 ++++ .../iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java | 3 +++ .../com/iemr/flw/service/impl/BeneficiaryServiceImpl.java | 2 ++ 3 files changed, 9 insertions(+) diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java index 84827388..dca4f5ee 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHBeneficiaryDetailsRmnch.java @@ -120,6 +120,10 @@ public class RMNCHBeneficiaryDetailsRmnch { @Column(name = "isGpsUnavailable", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean isGpsUnavailable = false; + @Expose + @Column(name = "gpsUnavailableReason") + private String gpsUnavailableReason; + @Expose @Column(name = "menstrualBFDId") private Integer menstrualBFDId; diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java index ea58143d..a589ec7a 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java @@ -262,4 +262,7 @@ public class RMNCHMBeneficiaryaddress implements Serializable { @Column(name = "isGpsUnavailable", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean isGpsUnavailable = false; + + @Column(name = "gpsUnavailableReason") + private String gpsUnavailableReason; } diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index dd1af6f5..bc146ca4 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -337,6 +337,8 @@ private String getMappingsForAddressIDs(List addressLi benDetailsRMNCH_OBJ.setGpsTimestamp(benAddressOBJ.getGpsTimestamp()); if (benDetailsRMNCH_OBJ.getIsGpsUnavailable() == null && benAddressOBJ.getIsGpsUnavailable() != null) benDetailsRMNCH_OBJ.setIsGpsUnavailable(benAddressOBJ.getIsGpsUnavailable()); + if (benDetailsRMNCH_OBJ.getGpsUnavailableReason() == null && benAddressOBJ.getGpsUnavailableReason() != null) + benDetailsRMNCH_OBJ.setGpsUnavailableReason(benAddressOBJ.getGpsUnavailableReason()); // Map GPS double fields to the exposed latitude/longitude BigDecimal fields for response if (benDetailsRMNCH_OBJ.getGpsLatitude() != null) From b92cdeaf45f686e0676de0c5b65cb85fe8b4c9ff Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 30 Jun 2026 11:02:14 +0530 Subject: [PATCH 12/47] Updated isMandatory field in Section C, so changed in TbCounsellingFormSeeder --- .../java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java index edb26b0f..0e956c9c 100644 --- a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java +++ b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java @@ -125,8 +125,8 @@ private FormSectionDTO buildSectionB() { private FormSectionDTO buildSectionC() { FormSectionDTO s = section("TB_SEC_C", "Government Schemes", "सरकारी योजनाएं", "PRE_SUBMIT", 3, false, false); List qs = new ArrayList<>(); - qs.add(yesNoRadio("TB_C_Q1", "Nikshay Poshan Yojana (NPY) eligibility explained", "निक्षय पोषण योजना (एनपीवाई) पात्रता के बारे में बताया गया", 1, false)); - qs.add(yesNoRadio("TB_C_Q2", "DOTS free treatment explained", "DOTS मुफ़्त इलाज के बारे में जानकारी", 2, false)); + qs.add(yesNoRadio("TB_C_Q1", "Nikshay Poshan Yojana (NPY) eligibility explained", "निक्षय पोषण योजना (एनपीवाई) पात्रता के बारे में बताया गया", 1, true)); + qs.add(yesNoRadio("TB_C_Q2", "DOTS free treatment explained", "DOTS मुफ़्त इलाज के बारे में जानकारी", 2, true)); qs.add(textQuestion("TB_C_REMARKS", "Schemes notes", "योजनाओं के नोट्स", 3, false, 300, true)); s.setQuestions(qs); return s; From 81e29d21b6ed3593c23dc4826a3c23ca8c6dd081 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Thu, 2 Jul 2026 19:16:41 +0530 Subject: [PATCH 13/47] Add optional villageId and providerServiceMapId filters to getCompletedBeneficiaries --- .../flw/controller/DynamicFormResponseController.java | 9 ++++++--- .../java/com/iemr/flw/repo/iemr/FormResponseRepo.java | 9 +++++++++ .../com/iemr/flw/service/DynamicFormResponseService.java | 4 ++-- .../flw/service/impl/DynamicFormResponseServiceImpl.java | 6 ++++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java index 65dd7036..49d8e547 100644 --- a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java +++ b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java @@ -98,10 +98,13 @@ public ResponseEntity getById(@RequestParam Long responseId) { FormResponseDTO dto = responseService.getResponseById(responseId); return ResponseEntity.ok(new ApiResponse(true, "Response fetched successfully", dto)); } - @Operation(summary = "Get beneficiary IDs that have a COMPLETE form response for the given form type") + @Operation(summary = "Get beneficiary IDs that have a COMPLETE form response for the given form type, optionally filtered by village and/or provider service map") @RequestMapping(value = "/getCompletedBeneficiaries", method = RequestMethod.GET) - public ResponseEntity getCompletedBeneficiaries(@RequestParam FormType formType) { - List beneficiaryIds = responseService.getCompletedBeneficiaries(formType); + public ResponseEntity getCompletedBeneficiaries( + @RequestParam FormType formType, + @RequestParam(required = false) Integer villageId, + @RequestParam(required = false) Integer providerServiceMapId) { + List beneficiaryIds = responseService.getCompletedBeneficiaries(formType, villageId, providerServiceMapId); return ResponseEntity.ok(new ApiResponse(true, "Completed beneficiaries fetched successfully", beneficiaryIds)); } } diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java index 02077c9f..66672c16 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java @@ -43,6 +43,15 @@ public interface FormResponseRepo extends JpaRepository { @Query("SELECT r.beneficiaryId FROM FormResponse r WHERE r.formId = :formId AND r.status = :status") List findBeneficiaryIdsByFormIdAndStatus(@Param("formId") Long formId, @Param("status") String status); + @Query("SELECT r.beneficiaryId FROM FormResponse r WHERE r.formId = :formId AND r.status = :status " + + "AND r.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBeneficiaryIdsByFormIdAndStatusFiltered(@Param("formId") Long formId, + @Param("status") String status, + @Param("villageId") Integer villageId, + @Param("providerServiceMapId") Integer providerServiceMapId); + @Query("SELECT r.beneficiaryId FROM FormResponse r " + "WHERE r.beneficiaryId IN :benIds " + "AND r.formId = :formId " + diff --git a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java index 436cae75..cca29e2e 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java @@ -61,6 +61,6 @@ public interface DynamicFormResponseService { */ List findPendingFollowUps(List formIds, int delayDays); - /** Returns beneficiary IDs with COMPLETE status for the given form type. */ - List getCompletedBeneficiaries(FormType formType); + /** Returns beneficiary IDs with COMPLETE status for the given form type, optionally filtered by village and/or provider service map. */ + List getCompletedBeneficiaries(FormType formType, Integer villageId, Integer providerServiceMapId); } diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index 8c8cc6ea..4553cc06 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -469,10 +469,12 @@ public List findPendingFollowUps(List formIds, int delayD @Override @Transactional(readOnly = true) - public List getCompletedBeneficiaries(FormType formType) { + public List getCompletedBeneficiaries(FormType formType, Integer villageId, Integer providerServiceMapId) { DynamicForm form = dynamicFormRepo.findByFormTypeAndIsActive(formType, true) .orElseThrow(() -> new NoSuchElementException( "No active form found for type: " + formType)); - return formResponseRepo.findBeneficiaryIdsByFormIdAndStatus(form.getFormId(), STATUS_COMPLETE); + return (villageId != null || providerServiceMapId != null) + ? formResponseRepo.findBeneficiaryIdsByFormIdAndStatusFiltered(form.getFormId(), STATUS_COMPLETE, villageId, providerServiceMapId) + : formResponseRepo.findBeneficiaryIdsByFormIdAndStatus(form.getFormId(), STATUS_COMPLETE); } } From be0d0d69392f3f956b5965ee105c8778a2ea1038 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Thu, 2 Jul 2026 19:36:53 +0530 Subject: [PATCH 14/47] Removing section f from seeder --- src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java index 0e956c9c..4fdd3a2d 100644 --- a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java +++ b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java @@ -85,7 +85,7 @@ private DynamicFormDTO buildFormDto() { sections.add(buildSectionC()); sections.add(buildSectionD()); sections.add(buildSectionE()); - sections.add(buildSectionF()); +// sections.add(buildSectionF()); dto.setSections(sections); return dto; } From f6aca3a8c92d18a7b392c62fd00cb7413b119906 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Thu, 2 Jul 2026 20:50:21 +0530 Subject: [PATCH 15/47] Removing already existing check in /complete api --- .../service/impl/DynamicFormResponseServiceImpl.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index 4553cc06..d1673a0e 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -124,19 +124,17 @@ public FormResponseDTO completeForm(FormResponseRequest request, String jwtToken FormVersion version = resolveLatestVersion(request.getFormUuid()); Long formId = version.getDynamicForm().getFormId(); + Timestamp now = new Timestamp(System.currentTimeMillis()); List existing = formResponseRepo.findByBeneficiaryIdAndFormId(request.getBeneficiaryId(), formId); - if (existing.isEmpty()) { - throw new NoSuchElementException( - "FormResponse not found for beneficiaryId=" + request.getBeneficiaryId() - + " formUuid=" + request.getFormUuid()); - } - FormResponse formResponse = existing.get(0); + FormResponse formResponse = existing.isEmpty() + ? createFormResponse(request, version, STATUS_SUBMITTED, now) + : existing.get(0); formResponse.setUpdatedBy(actor); formResponse.setStatus(STATUS_COMPLETE); - formResponse.setCompletedAt(new Timestamp(System.currentTimeMillis())); + formResponse.setCompletedAt(now); formResponse = formResponseRepo.save(formResponse); return processSections(formResponse, request, version, actor); } From 41550c28f51812789b1e6dbe1f174ed4be491f06 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Sun, 5 Jul 2026 22:44:09 +0530 Subject: [PATCH 16/47] Add a CHECKBOX question type and a TB_COUNSELLING_V2 seeder with a GENERAL_INFO consent gate ahead of the counselling sections. --- .../DynamicFormResponseController.java | 2 +- .../com/iemr/flw/domain/iemr/FormSection.java | 11 +- .../iemr/flw/domain/iemr/SectionQuestion.java | 2 +- .../com/iemr/flw/dto/iemr/FormSectionDTO.java | 7 +- .../iemr/flw/dto/iemr/SectionResponseDTO.java | 2 + .../com/iemr/flw/masterEnum/FormType.java | 3 +- .../com/iemr/flw/masterEnum/QuestionType.java | 3 + .../com/iemr/flw/masterEnum/SectionPhase.java | 37 +++ .../flw/seeder/TbCounsellingFormSeeder.java | 15 +- .../flw/seeder/TbCounsellingV2FormSeeder.java | 292 ++++++++++++++++++ .../service/DynamicFormResponseService.java | 2 +- .../impl/DynamicFormResponseServiceImpl.java | 41 ++- .../service/impl/FormResponseItemSaver.java | 30 +- 13 files changed, 410 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/iemr/flw/masterEnum/SectionPhase.java create mode 100644 src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java diff --git a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java index 49d8e547..99ebb9f7 100644 --- a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java +++ b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java @@ -53,7 +53,7 @@ public class DynamicFormResponseController { private final DynamicFormResponseService responseService; - @Operation(summary = "Submit PRE_SUBMIT section answers, status → SUBMITTED. " + + @Operation(summary = "Submit GENERAL_INFO and PRE_SUBMIT section answers, status → SUBMITTED. " + "Pass responseId in body to re-submit an existing SUBMITTED response.") @RequestMapping(value = "/submit", method = RequestMethod.POST) public ResponseEntity submitForm( diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java index b8454716..28f50859 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java @@ -21,9 +21,12 @@ */ package com.iemr.flw.domain.iemr; +import com.iemr.flw.masterEnum.SectionPhase; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -71,9 +74,9 @@ public class FormSection { @Column(name = "sectionName_hindi", length = 255) private String sectionNameHindi; - /** PRE_SUBMIT sections are shown before the form's main submit. POST_SUBMIT after. */ + @Enumerated(EnumType.STRING) @Column(name = "sectionPhase", nullable = false, length = 20) - private String sectionPhase; + private SectionPhase sectionPhase; @Column(name = "isRequired", nullable = false) private Boolean isRequired = true; @@ -81,10 +84,12 @@ public class FormSection { @Column(name = "displayOrder", nullable = false) private Integer displayOrder; - /** True only on the last PRE_SUBMIT section — renders the Submit button. */ @Column(name = "hasSubmitButton", nullable = false) private Boolean hasSubmitButton = false; + @Column(name = "isEditable", nullable = false) + private Boolean isEditable = false; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 1bd7991b..91cc3c43 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -47,7 +47,7 @@ /** * A question within a form section. - * questionType: RADIO | MCQ | TEXT | DATE | DISPLAY | AUTO_FILL + * questionType: RADIO | MCQ | CHECKBOX | TEXT | DATE | DISPLAY | AUTO_FILL */ @Entity @Table(name = "t_section_question", schema = "db_iemr") diff --git a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java index 2db1f8c7..6bb10795 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java @@ -21,6 +21,7 @@ */ package com.iemr.flw.dto.iemr; +import com.iemr.flw.masterEnum.SectionPhase; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -49,8 +50,8 @@ public class FormSectionDTO { private String sectionNameHindi; - @NotBlank(message = "sectionPhase is required (PRE_SUBMIT or POST_SUBMIT)") - private String sectionPhase; + @NotNull(message = "sectionPhase is required (GENERAL_INFO, PRE_SUBMIT, or POST_SUBMIT)") + private SectionPhase sectionPhase; private Boolean isRequired = true; @@ -59,6 +60,8 @@ public class FormSectionDTO { private Boolean hasSubmitButton = false; + private Boolean isEditable = false; + @Valid private List questions = new ArrayList<>(); } diff --git a/src/main/java/com/iemr/flw/dto/iemr/SectionResponseDTO.java b/src/main/java/com/iemr/flw/dto/iemr/SectionResponseDTO.java index dac289a7..78c918fe 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/SectionResponseDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/SectionResponseDTO.java @@ -42,6 +42,8 @@ public class SectionResponseDTO { private Long sectionResponseId; private Long sectionId; + private String sectionUuid; + private Boolean isEditable; private String status; private Timestamp savedAt; private List answers; diff --git a/src/main/java/com/iemr/flw/masterEnum/FormType.java b/src/main/java/com/iemr/flw/masterEnum/FormType.java index 779fce19..ccb0cfe2 100644 --- a/src/main/java/com/iemr/flw/masterEnum/FormType.java +++ b/src/main/java/com/iemr/flw/masterEnum/FormType.java @@ -30,5 +30,6 @@ public enum FormType { COUNSELLING_MODULE, SCREENING, COUNSELLING, - TB_COUNSELLING + TB_COUNSELLING, + TB_COUNSELLING_V2 } diff --git a/src/main/java/com/iemr/flw/masterEnum/QuestionType.java b/src/main/java/com/iemr/flw/masterEnum/QuestionType.java index ae4b91e1..0af07aa1 100644 --- a/src/main/java/com/iemr/flw/masterEnum/QuestionType.java +++ b/src/main/java/com/iemr/flw/masterEnum/QuestionType.java @@ -32,6 +32,9 @@ public enum QuestionType { RADIO, /** Multi-select from a predefined list of options. */ MCQ, + /** Single boolean checkbox — checked or unchecked. May carry options for display + * purposes; answers are still stored as free text. */ + CHECKBOX, /** Free-text input. */ TEXT, /** Date picker input. */ diff --git a/src/main/java/com/iemr/flw/masterEnum/SectionPhase.java b/src/main/java/com/iemr/flw/masterEnum/SectionPhase.java new file mode 100644 index 00000000..13faa60d --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/SectionPhase.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.flw.masterEnum; + +/** + * Exhaustive set of phases a form section can belong to. + * No other values may be stored in t_form_section.sectionPhase. + * + * @author Piramal Swasthya + */ +public enum SectionPhase { + /** Shown first, before PRE_SUBMIT sections. */ + GENERAL_INFO, + /** Shown before the form's main submit. Saved via /submit. */ + PRE_SUBMIT, + /** Shown after the main submit. Saved via /complete. */ + POST_SUBMIT +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java index 4fdd3a2d..ecddba20 100644 --- a/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java +++ b/src/main/java/com/iemr/flw/seeder/TbCounsellingFormSeeder.java @@ -29,6 +29,7 @@ import com.iemr.flw.dto.iemr.SectionQuestionDTO; import com.iemr.flw.masterEnum.FormType; import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.SectionPhase; import com.iemr.flw.masterEnum.ValidationType; import com.iemr.flw.repo.iemr.DynamicFormRepo; import com.iemr.flw.service.DynamicFormDefinitionService; @@ -93,7 +94,7 @@ private DynamicFormDTO buildFormDto() { // ── Section A: Disease Awareness ───────────────────────────────────────────── private FormSectionDTO buildSectionA() { - FormSectionDTO s = section("TB_SEC_A", "Disease Awareness", "बीमारी के बारे में जागरूकता", "PRE_SUBMIT", 1, true, false); + FormSectionDTO s = section("TB_SEC_A", "Disease Awareness", "बीमारी के बारे में जागरूकता", SectionPhase.PRE_SUBMIT, 1, true, false); List qs = new ArrayList<>(); qs.add(yesNoRadio("TB_A_Q1", "TB disease explained to patient", "मरीज़ को टीबी बीमारी के बारे में समझाया गया।", 1, true)); qs.add(yesNoRadio("TB_A_Q2", "Transmission route explained", "ट्रांसमिशन के तरीके के बारे में जानकारी", 2, true)); @@ -107,7 +108,7 @@ private FormSectionDTO buildSectionA() { // ── Section B: Do's and Don'ts ─────────────────────────────────────────────── private FormSectionDTO buildSectionB() { - FormSectionDTO s = section("TB_SEC_B", "Do's and Don'ts", "करो और ना करो", "PRE_SUBMIT", 2, true, false); + FormSectionDTO s = section("TB_SEC_B", "Do's and Don'ts", "करो और ना करो", SectionPhase.PRE_SUBMIT, 2, true, false); List qs = new ArrayList<>(); qs.add(yesNoRadio("TB_B_Q1", "Cover mouth while coughing — advised", "खांसते समय मुंह ढकने की सलाह दी जाती है।", 1, true)); qs.add(yesNoRadio("TB_B_Q2", "Complete full treatment course — advised", "इलाज का पूरा कोर्स पूरा करने की सलाह दी जाती है।", 2, true)); @@ -123,7 +124,7 @@ private FormSectionDTO buildSectionB() { // ── Section C: Government Schemes ──────────────────────────────────────────── private FormSectionDTO buildSectionC() { - FormSectionDTO s = section("TB_SEC_C", "Government Schemes", "सरकारी योजनाएं", "PRE_SUBMIT", 3, false, false); + FormSectionDTO s = section("TB_SEC_C", "Government Schemes", "सरकारी योजनाएं", SectionPhase.PRE_SUBMIT, 3, false, false); List qs = new ArrayList<>(); qs.add(yesNoRadio("TB_C_Q1", "Nikshay Poshan Yojana (NPY) eligibility explained", "निक्षय पोषण योजना (एनपीवाई) पात्रता के बारे में बताया गया", 1, true)); qs.add(yesNoRadio("TB_C_Q2", "DOTS free treatment explained", "DOTS मुफ़्त इलाज के बारे में जानकारी", 2, true)); @@ -135,7 +136,7 @@ private FormSectionDTO buildSectionC() { // ── Section D: Treatment Regimen ───────────────────────────────────────────── private FormSectionDTO buildSectionD() { - FormSectionDTO s = section("TB_SEC_D", "Treatment Regimen", "इलाज का तरीका", "PRE_SUBMIT", 4, true, false); + FormSectionDTO s = section("TB_SEC_D", "Treatment Regimen", "इलाज का तरीका", SectionPhase.PRE_SUBMIT, 4, true, false); List qs = new ArrayList<>(); qs.add(yesNoRadio("TB_D_Q1", "Regimen explained to patient", "मरीज़ को इलाज का तरीका समझाया गया।", 1, true)); qs.add(yesNoRadio("TB_D_Q2", "Medication names explained", "दवाओं के नामों की जानकारी", 2, true)); @@ -149,7 +150,7 @@ private FormSectionDTO buildSectionD() { // ── Section E: Counselling Completion ──────────────────────────────────────── private FormSectionDTO buildSectionE() { - FormSectionDTO s = section("TB_SEC_E", "Counselling Completion", "काउंसलिंग पूरी होना", "PRE_SUBMIT", 5, true, true); + FormSectionDTO s = section("TB_SEC_E", "Counselling Completion", "काउंसलिंग पूरी होना", SectionPhase.PRE_SUBMIT, 5, true, true); List qs = new ArrayList<>(); // Counselling completion status — RADIO: Complete | Refused @@ -194,7 +195,7 @@ private FormSectionDTO buildSectionE() { // ── Section F: Follow Up to TU ─────────────────────────────────────────────── private FormSectionDTO buildSectionF() { - FormSectionDTO s = section("TB_SEC_F", "Follow Up to TU", "TU के बाद की कार्रवाई", "POST_SUBMIT", 6, true, true); + FormSectionDTO s = section("TB_SEC_F", "Follow Up to TU", "TU के बाद की कार्रवाई", SectionPhase.POST_SUBMIT, 6, true, true); List qs = new ArrayList<>(); // "Has the patient started TB treatment?" — RADIO: Yes | No @@ -244,7 +245,7 @@ private FormSectionDTO buildSectionF() { // ── Builder Helpers ─────────────────────────────────────────────────────────── - private FormSectionDTO section(String uuid, String name, String nameHindi, String phase, + private FormSectionDTO section(String uuid, String name, String nameHindi, SectionPhase phase, int order, boolean required, boolean hasSubmitButton) { FormSectionDTO s = new FormSectionDTO(); s.setSectionUuid(uuid); diff --git a/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java b/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java new file mode 100644 index 00000000..5780eb76 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java @@ -0,0 +1,292 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder; + +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.FormType; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.SectionPhase; +import com.iemr.flw.masterEnum.ValidationType; +import com.iemr.flw.repo.iemr.DynamicFormRepo; +import com.iemr.flw.service.DynamicFormDefinitionService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Seeds the TB Counselling (v2) form definition on application startup if it does not already exist. + * Idempotent: skips creation if the form UUID is already present in the database. + * + * Adds an initial GENERAL_INFO consent gate ("Has the beneficiary agreed for counselling?") ahead + * of the PRE_SUBMIT sections, and uses CHECKBOX questions in place of Yes/No RADIO questions. + * + * @author Piramal Swasthya + */ +@Component +@RequiredArgsConstructor +public class TbCounsellingV2FormSeeder { + + private static final Logger log = LoggerFactory.getLogger(TbCounsellingV2FormSeeder.class); + + public static final String FORM_UUID = "TB_COUNSELLING_V2"; + + private final DynamicFormDefinitionService formService; + private final DynamicFormRepo formRepo; + + @PostConstruct + public void seed() { + if (formRepo.findByFormUuid(FORM_UUID).isPresent()) { + log.info("TbCounsellingV2FormSeeder: form '{}' already exists — skipping seed.", FORM_UUID); + return; + } + log.info("TbCounsellingV2FormSeeder: seeding TB Counselling (v2) form..."); + formService.createForm(buildFormDto()); + log.info("TbCounsellingV2FormSeeder: seed complete."); + } + + // ── Form Definition ────────────────────────────────────────────────────────── + + private DynamicFormDTO buildFormDto() { + DynamicFormDTO dto = new DynamicFormDTO(); + dto.setFormUuid(FORM_UUID); + dto.setFormName("TB Counselling"); + dto.setFormType(FormType.TB_COUNSELLING_V2); + dto.setIsActive(true); + dto.setFollowUpDelayDays(15); + + List sections = new ArrayList<>(); + sections.add(buildGeneralInfoSection()); + sections.add(buildSectionA()); + sections.add(buildSectionB()); + sections.add(buildSectionC()); + sections.add(buildSectionD()); + sections.add(buildSectionE()); + dto.setSections(sections); + return dto; + } + + // ── General Info: Counselling Consent ──────────────────────────────────────── + + private FormSectionDTO buildGeneralInfoSection() { + FormSectionDTO s = section("TB2_SEC_GENERAL_INFO", "General Information", "सामान्य जानकारी", + SectionPhase.GENERAL_INFO, 1, true, false, false); + List qs = new ArrayList<>(); + + // "Has the beneficiary agreed for counselling?" — RADIO: Yes | No + SectionQuestionDTO consentQ = new SectionQuestionDTO(); + consentQ.setQuestionUuid("TB2_GI_Q1"); + consentQ.setQuestionText("Has the beneficiary agreed for counselling?"); + consentQ.setQuestionTextHindi("क्या लाभार्थी काउंसलिंग के लिए सहमत हुआ है?"); + consentQ.setQuestionType(QuestionType.CHECKBOX); + consentQ.setIsMandatory(true); + consentQ.setDisplayOrder(1); + consentQ.setVisibleByDefault(true); + + // "Yes" option — no conditions, sections A-E remain enabled by default + QuestionOptionDTO yesOpt = option("Yes", "हाँ", "YES", "हाँ", 1, List.of()); + + // "No" option — disable validation for sections A-E + show refusal reason field + List noConditions = new ArrayList<>(); + noConditions.add(disableSectionValidation("TB2_SEC_A")); + noConditions.add(disableSectionValidation("TB2_SEC_B")); + noConditions.add(disableSectionValidation("TB2_SEC_C")); + noConditions.add(disableSectionValidation("TB2_SEC_D")); + noConditions.add(disableSectionValidation("TB2_SEC_E")); + noConditions.add(showQuestion("TB2_GI_REFUSAL")); + QuestionOptionDTO noOpt = option("No", "नहीं", "NO", "नहीं", 2, noConditions); + + List consentOptions = new ArrayList<>(); + consentOptions.add(yesOpt); + consentOptions.add(noOpt); + consentQ.setOptions(consentOptions); + consentQ.setValidations(List.of()); + qs.add(consentQ); + + // Reason for refusal — hidden by default, mandatory when shown, max 300 chars + qs.add(textQuestion("TB2_GI_REFUSAL", "Reason for refusal", "इनकार का कारण", 2, true, 300, false)); + + s.setQuestions(qs); + return s; + } + + // ── Section A: Disease Awareness ───────────────────────────────────────────── + + private FormSectionDTO buildSectionA() { + FormSectionDTO s = section("TB2_SEC_A", "Disease Awareness", "बीमारी के बारे में जागरूकता", SectionPhase.PRE_SUBMIT, 2, true, false, false); + List qs = new ArrayList<>(); + qs.add(checkboxQuestion("TB2_A_Q1", "TB disease explained to patient", "मरीज़ को टीबी बीमारी के बारे में समझाया गया।", 1, true)); + qs.add(checkboxQuestion("TB2_A_Q2", "Transmission route explained", "ट्रांसमिशन के तरीके के बारे में जानकारी", 2, true)); + qs.add(checkboxQuestion("TB2_A_Q3", "Symptoms explained", "लक्षणों की जानकारी", 3, true)); + qs.add(checkboxQuestion("TB2_A_Q4", "Treatment duration explained", "इलाज की अवधि के बारे में जानकारी", 4, true)); + qs.add(textQuestion("TB2_A_REMARKS", "Disease awareness notes", "बीमारी के बारे में जानकारी देने वाले नोट्स", 5, false, 500, true)); + s.setQuestions(qs); + return s; + } + + // ── Section B: Do's and Don'ts ─────────────────────────────────────────────── + + private FormSectionDTO buildSectionB() { + FormSectionDTO s = section("TB2_SEC_B", "Do's and Don'ts", "करो और ना करो", SectionPhase.PRE_SUBMIT, 3, true, false, false); + List qs = new ArrayList<>(); + qs.add(checkboxQuestion("TB2_B_Q1", "Cover mouth while coughing — advised", "खांसते समय मुंह ढकने की सलाह दी जाती है।", 1, true)); + qs.add(checkboxQuestion("TB2_B_Q2", "Complete full treatment course — advised", "इलाज का पूरा कोर्स पूरा करने की सलाह दी जाती है।", 2, true)); + qs.add(checkboxQuestion("TB2_B_Q3", "Regular follow-up attendance — advised", "इलाज का पूरा कोर्स पूरा करने की सलाह दी जाती है।", 3, true)); + qs.add(checkboxQuestion("TB2_B_Q4", "Nutritional guidance provided", "पोषण संबंधी मार्गदर्शन प्रदान किया गया", 4, true)); + qs.add(checkboxQuestion("TB2_B_Q5", "No smoking / alcohol — advised", "धूम्रपान / शराब न लेने की सलाह दी जाती है।", 5, true)); + qs.add(checkboxQuestion("TB2_B_Q6", "Isolation precautions explained", "अलगाव सावधानियों के बारे में बताया गया", 6, true)); + qs.add(textQuestion("TB2_B_REMARKS", "Do's & Don'ts notes", "क्या करें और क्या न करें - नोट्स", 7, false, 500, true)); + s.setQuestions(qs); + return s; + } + + // ── Section C: Government Schemes ──────────────────────────────────────────── + + private FormSectionDTO buildSectionC() { + FormSectionDTO s = section("TB2_SEC_C", "Government Schemes", "सरकारी योजनाएं", SectionPhase.PRE_SUBMIT, 4, true, false, false); + List qs = new ArrayList<>(); + qs.add(checkboxQuestion("TB2_C_Q1", "Nikshay Poshan Yojana (NPY) eligibility explained", "निक्षय पोषण योजना (एनपीवाई) पात्रता के बारे में बताया गया", 1, true)); + qs.add(checkboxQuestion("TB2_C_Q2", "DOTS free treatment explained", "DOTS मुफ़्त इलाज के बारे में जानकारी", 2, true)); + qs.add(textQuestion("TB2_C_REMARKS", "Schemes notes", "योजनाओं के नोट्स", 3, false, 300, true)); + s.setQuestions(qs); + return s; + } + + // ── Section D: Treatment Regimen ───────────────────────────────────────────── + + private FormSectionDTO buildSectionD() { + FormSectionDTO s = section("TB2_SEC_D", "Treatment Regimen", "इलाज का तरीका", SectionPhase.PRE_SUBMIT, 5, true, false, false); + List qs = new ArrayList<>(); + qs.add(checkboxQuestion("TB2_D_Q1", "Regimen explained to patient", "मरीज़ को इलाज का तरीका समझाया गया।", 1, true)); + qs.add(checkboxQuestion("TB2_D_Q2", "Medication names explained", "दवाओं के नामों की जानकारी", 2, true)); + qs.add(checkboxQuestion("TB2_D_Q3", "Side effects explained", "साइड इफ़ेक्ट्स के बारे में जानकारी", 3, true)); + qs.add(checkboxQuestion("TB2_D_Q4", "Importance of adherence explained", "निर्देशों का पालन करने का महत्व समझाया गया", 4, true)); + qs.add(textQuestion("TB2_D_REMARKS", "Treatment regimen notes", "इलाज के तरीके से जुड़े नोट्स", 5, false, 300, true)); + s.setQuestions(qs); + return s; + } + + // ── Section E: Impact Assessment ───────────────────────────────────────────── + + private FormSectionDTO buildSectionE() { + FormSectionDTO s = section("TB2_SEC_E", "Impact Assessment", "प्रभाव का आकलन", SectionPhase.PRE_SUBMIT, 6, true, true, true); + List qs = new ArrayList<>(); + qs.add(textQuestion("TB2_E_Q1", + "How has TB disease impacted the life of this beneficiary", + "टीबी बीमारी ने इस लाभार्थी के जीवन को कैसे प्रभावित किया है", + 1, false, 400, true)); + s.setQuestions(qs); + return s; + } + + // ── Builder Helpers ─────────────────────────────────────────────────────────── + + private FormSectionDTO section(String uuid, String name, String nameHindi, SectionPhase phase, + int order, boolean required, boolean hasSubmitButton, boolean isEditable) { + FormSectionDTO s = new FormSectionDTO(); + s.setSectionUuid(uuid); + s.setSectionName(name); + s.setSectionNameHindi(nameHindi); + s.setSectionPhase(phase); + s.setDisplayOrder(order); + s.setIsRequired(required); + s.setHasSubmitButton(hasSubmitButton); + s.setIsEditable(isEditable); + s.setQuestions(new ArrayList<>()); + return s; + } + + private SectionQuestionDTO checkboxQuestion(String uuid, String text, String textHindi, int order, boolean mandatory) { + SectionQuestionDTO q = new SectionQuestionDTO(); + q.setQuestionUuid(uuid); + q.setQuestionText(text); + q.setQuestionTextHindi(textHindi); + q.setQuestionType(QuestionType.CHECKBOX); + q.setIsMandatory(mandatory); + q.setDisplayOrder(order); + q.setVisibleByDefault(true); + + List opts = new ArrayList<>(); + opts.add(option("Yes", "हाँ", "YES", "हाँ", 1, List.of())); + opts.add(option("No", "नहीं", "NO", "नहीं", 2, List.of())); + q.setOptions(opts); + + q.setValidations(List.of()); + return q; + } + + private SectionQuestionDTO textQuestion(String uuid, String text, String textHindi, int order, + boolean mandatory, int maxLength, boolean visible) { + SectionQuestionDTO q = new SectionQuestionDTO(); + q.setQuestionUuid(uuid); + q.setQuestionText(text); + q.setQuestionTextHindi(textHindi); + q.setQuestionType(QuestionType.TEXT); + q.setIsMandatory(mandatory); + q.setDisplayOrder(order); + q.setMaxLength(maxLength); + q.setVisibleByDefault(visible); + q.setOptions(List.of()); + + QuestionValidationDTO v = new QuestionValidationDTO(); + v.setValidationType(ValidationType.MAX_LENGTH); + v.setValidationParam(String.valueOf(maxLength)); + v.setErrorMessage("Must be " + maxLength + " characters or fewer"); + q.setValidations(List.of(v)); + return q; + } + + private QuestionOptionDTO option(String label, String labelHindi, String value, String valueHindi, int order, + List conditions) { + QuestionOptionDTO o = new QuestionOptionDTO(); + o.setOptionLabel(label); + o.setOptionLabelHindi(labelHindi); + o.setOptionValue(value); + o.setOptionValueHindi(valueHindi); + o.setDisplayOrder(order); + o.setConditions(conditions); + return o; + } + + private OptionConditionDTO showQuestion(String targetQuestionUuid) { + OptionConditionDTO c = new OptionConditionDTO(); + c.setActionType("SHOW_QUESTION"); + c.setTargetQuestionUuid(targetQuestionUuid); + return c; + } + + private OptionConditionDTO disableSectionValidation(String targetSectionUuid) { + OptionConditionDTO c = new OptionConditionDTO(); + c.setActionType("DISABLE_SECTION_VALIDATION"); + c.setTargetSectionUuid(targetSectionUuid); + return c; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java index cca29e2e..c6929e62 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java @@ -35,7 +35,7 @@ */ public interface DynamicFormResponseService { - /** Save PRE_SUBMIT section answers and advance status to SUBMITTED. */ + /** Save GENERAL_INFO and PRE_SUBMIT section answers and advance status to SUBMITTED. */ FormResponseDTO submitForm(FormResponseRequest request); /** Save POST_SUBMIT section answers and advance status to COMPLETE. */ diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index d1673a0e..0bcd49df 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -45,6 +45,7 @@ import com.iemr.flw.repo.iemr.SectionQuestionRepo; import com.iemr.flw.repo.iemr.SectionResponseRepo; import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.SectionPhase; import com.iemr.flw.service.DynamicFormResponseService; import com.iemr.flw.utils.JwtUtil; import com.iemr.flw.utils.exception.IEMRException; @@ -240,10 +241,14 @@ private FormResponseDTO processSections( "' not found in form '" + request.getFormUuid() + "'"); } - SectionResponse sectionResponse = upsertSectionResponse( - formResponse.getResponseId(), section.getSectionId(), actor); + Optional sectionResponseOpt = upsertSectionResponse( + formResponse.getResponseId(), section, actor); + if (sectionResponseOpt.isEmpty()) { + continue; + } + SectionResponse sectionResponse = sectionResponseOpt.get(); - if ("POST_SUBMIT".equals(section.getSectionPhase())) { + if (section.getSectionPhase() == SectionPhase.POST_SUBMIT) { formResponse.setLastFollowUpAt(sectionResponse.getSavedAt()); formResponseRepo.save(formResponse); } @@ -260,31 +265,36 @@ private FormResponseDTO processSections( questionResponseRepo.saveAll(questionResponses); - sectionDTOs.add(buildSectionResponseDTO(sectionResponse, questionResponses)); + sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses)); } return buildFormResponseDTO(formResponse, sectionDTOs); } - private SectionResponse upsertSectionResponse(Long responseId, Long sectionId, String actor) { + private Optional upsertSectionResponse(Long responseId, FormSection section, String actor) { Optional existing = - sectionResponseRepo.findByResponseIdAndSectionId(responseId, sectionId); + sectionResponseRepo.findByResponseIdAndSectionId(responseId, section.getSectionId()); if (existing.isPresent()) { + if (Boolean.FALSE.equals(section.getIsEditable())) { + log.info("Section '{}' is not editable — skipping update for responseId={}", + section.getSectionUuid(), responseId); + return Optional.empty(); + } SectionResponse sr = existing.get(); sr.setStatus(SECTION_STATUS_DONE); sr.setSavedAt(new Timestamp(System.currentTimeMillis())); sr.setUpdatedBy(actor); - return sectionResponseRepo.save(sr); + return Optional.of(sectionResponseRepo.save(sr)); } SectionResponse sr = SectionResponse.builder() .responseId(responseId) - .sectionId(sectionId) + .sectionId(section.getSectionId()) .status(SECTION_STATUS_DONE) .savedAt(new Timestamp(System.currentTimeMillis())) .createdBy(actor) .updatedBy(actor) .build(); - return sectionResponseRepo.save(sr); + return Optional.of(sectionResponseRepo.save(sr)); } private List processAnswers( @@ -344,7 +354,7 @@ private List processAnswers( } } } else { - // TEXT, DATE, AUTO_FILL — prefer answerText, then answerDate, then optionValue (legacy) + // TEXT, DATE, AUTO_FILL, CHECKBOX — prefer answerText, then answerDate, then optionValue (legacy) String value = answer.getAnswerText() != null ? answer.getAnswerText() : answer.getAnswerDate() != null ? answer.getAnswerDate() : answer.getOptionValue(); @@ -390,9 +400,16 @@ private List loadSectionResponseDTOs(Long responseId) { Map> answersBySectionResponse = allAnswers.stream() .collect(Collectors.groupingBy(QuestionResponse::getSectionResponseId)); + Set sectionIds = sections.stream() + .map(SectionResponse::getSectionId) + .collect(Collectors.toSet()); + Map sectionById = formSectionRepo.findAllById(sectionIds).stream() + .collect(Collectors.toMap(FormSection::getSectionId, Function.identity())); + return sections.stream() .map(sr -> buildSectionResponseDTO( sr, + sectionById.get(sr.getSectionId()), answersBySectionResponse.getOrDefault(sr.getSectionResponseId(), List.of()))) .collect(Collectors.toList()); } @@ -417,7 +434,7 @@ private FormResponseDTO buildFormResponseDTO(FormResponse r, List answers) { + SectionResponse sr, FormSection section, List answers) { List answerDTOs = answers.stream() .map(a -> QuestionResponseDTO.builder() .questionResponseId(a.getQuestionResponseId()) @@ -429,6 +446,8 @@ private SectionResponseDTO buildSectionResponseDTO( return SectionResponseDTO.builder() .sectionResponseId(sr.getSectionResponseId()) .sectionId(sr.getSectionId()) + .sectionUuid(section != null ? section.getSectionUuid() : null) + .isEditable(section != null ? section.getIsEditable() : null) .status(sr.getStatus()) .savedAt(sr.getSavedAt()) .answers(answerDTOs) diff --git a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java index 0d844eb7..016681f3 100644 --- a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java +++ b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java @@ -42,6 +42,7 @@ import com.iemr.flw.repo.iemr.SectionQuestionRepo; import com.iemr.flw.repo.iemr.SectionResponseRepo; import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.SectionPhase; import com.iemr.flw.utils.JwtUtil; import com.iemr.flw.utils.exception.IEMRException; import lombok.RequiredArgsConstructor; @@ -224,18 +225,24 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR "' not found in form '" + req.getFormUuid() + "'"); } + Optional existingSr = sectionResponseRepo.findByResponseIdAndSectionId( + formResponse.getResponseId(), section.getSectionId()); + if (existingSr.isPresent() && Boolean.FALSE.equals(section.getIsEditable())) { + log.info("saveForBulk: section '{}' is not editable — skipping update for responseId={}", + section.getSectionUuid(), formResponse.getResponseId()); + continue; + } + // Delete existing SectionResponse (and its QuestionResponses) for this section only - sectionResponseRepo.findByResponseIdAndSectionId( - formResponse.getResponseId(), section.getSectionId()) - .ifPresent(sr -> { - questionResponseRepo.deleteBySectionResponseIdIn(Set.of(sr.getSectionResponseId())); - sectionResponseRepo.deleteById(sr.getSectionResponseId()); - }); + existingSr.ifPresent(sr -> { + questionResponseRepo.deleteBySectionResponseIdIn(Set.of(sr.getSectionResponseId())); + sectionResponseRepo.deleteById(sr.getSectionResponseId()); + }); SectionResponse sectionResponse = upsertSectionResponse( formResponse.getResponseId(), section.getSectionId(), actor); - if ("POST_SUBMIT".equals(section.getSectionPhase())) { + if (section.getSectionPhase() == SectionPhase.POST_SUBMIT) { formResponse.setLastFollowUpAt(sectionResponse.getSavedAt()); formResponseRepo.save(formResponse); } @@ -251,7 +258,7 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR actor); questionResponseRepo.saveAll(questionResponses); - sectionDTOs.add(buildSectionResponseDTO(sectionResponse, questionResponses)); + sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses)); } return buildFormResponseDTO(formResponse, sectionDTOs); @@ -332,7 +339,7 @@ private List processAnswers( } } } else { - // TEXT, DATE, AUTO_FILL — prefer answerText, then answerDate, then optionValue (legacy) + // TEXT, DATE, AUTO_FILL, CHECKBOX — prefer answerText, then answerDate, then optionValue (legacy) String value = answer.getAnswerText() != null ? answer.getAnswerText() : answer.getAnswerDate() != null ? answer.getAnswerDate() : answer.getOptionValue(); @@ -378,7 +385,8 @@ private FormResponseDTO buildFormResponseDTO(FormResponse r, List answers) { + private SectionResponseDTO buildSectionResponseDTO( + SectionResponse sr, FormSection section, List answers) { List answerDTOs = answers.stream() .map(a -> QuestionResponseDTO.builder() .questionResponseId(a.getQuestionResponseId()) @@ -390,6 +398,8 @@ private SectionResponseDTO buildSectionResponseDTO(SectionResponse sr, List Date: Mon, 6 Jul 2026 16:12:47 +0530 Subject: [PATCH 17/47] Seed single-checkbox questions for Sections A-D and point the Counselled lookup at TB_COUNSELLING_V2 instead of the retired V1 form --- .../com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java | 9 ++------- .../flw/service/impl/TBConfirmedCaseServiceImpl.java | 4 ++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java b/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java index 5780eb76..59690f5b 100644 --- a/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java +++ b/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java @@ -106,7 +106,7 @@ private FormSectionDTO buildGeneralInfoSection() { consentQ.setQuestionUuid("TB2_GI_Q1"); consentQ.setQuestionText("Has the beneficiary agreed for counselling?"); consentQ.setQuestionTextHindi("क्या लाभार्थी काउंसलिंग के लिए सहमत हुआ है?"); - consentQ.setQuestionType(QuestionType.CHECKBOX); + consentQ.setQuestionType(QuestionType.RADIO); consentQ.setIsMandatory(true); consentQ.setDisplayOrder(1); consentQ.setVisibleByDefault(true); @@ -233,12 +233,7 @@ private SectionQuestionDTO checkboxQuestion(String uuid, String text, String tex q.setIsMandatory(mandatory); q.setDisplayOrder(order); q.setVisibleByDefault(true); - - List opts = new ArrayList<>(); - opts.add(option("Yes", "हाँ", "YES", "हाँ", 1, List.of())); - opts.add(option("No", "नहीं", "NO", "नहीं", 2, List.of())); - q.setOptions(opts); - + q.setOptions(List.of(option("Checked", "चेक किया गया", "CHECKED", "चेक किया गया", 1, List.of()))); q.setValidations(List.of()); return q; } diff --git a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java index 94784d29..01ab464a 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java @@ -9,7 +9,7 @@ import com.iemr.flw.repo.iemr.DynamicFormRepo; import com.iemr.flw.repo.iemr.FormResponseRepo; import com.iemr.flw.repo.iemr.TBConfirmedTreatmentRepository; -import com.iemr.flw.seeder.TbCounsellingFormSeeder; +import com.iemr.flw.seeder.TbCounsellingV2FormSeeder; import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.TBConfirmedCaseService; import com.iemr.flw.service.TBStopVisitService; @@ -150,7 +150,7 @@ public String getByUserId(String authorisation) throws Exception { List benIds = dtoList.stream().map(TBConfirmedCaseDTO::getBenId).collect(Collectors.toList()); Set counselledBenIds = Collections.emptySet(); if (!benIds.isEmpty()) { - Optional counsellingForm = dynamicFormRepo.findByFormUuid(TbCounsellingFormSeeder.FORM_UUID); + Optional counsellingForm = dynamicFormRepo.findByFormUuid(TbCounsellingV2FormSeeder.FORM_UUID); if (counsellingForm.isPresent()) { counselledBenIds = new HashSet<>(formResponseRepo.findCounselledBenIds(benIds, counsellingForm.get().getFormId(), "COMPLETE")); } From 4aafc4b02d1555433cff61b08d209001068b4ab5 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 6 Jul 2026 19:27:45 +0530 Subject: [PATCH 18/47] Mark form/section status REFUSED when TB2_GI_Q1=NO on /complete, split getCompletedBeneficiaries into completed/refused lists, and extract FormResponseStatus enum --- .../DynamicFormResponseController.java | 8 +- .../dto/iemr/CompletedBeneficiariesDTO.java | 44 ++++++++++ .../flw/masterEnum/FormResponseStatus.java | 36 ++++++++ .../flw/seeder/TbCounsellingV2FormSeeder.java | 2 +- .../service/DynamicFormResponseService.java | 8 +- .../impl/DynamicFormResponseServiceImpl.java | 87 +++++++++++++++---- .../service/impl/FormResponseItemSaver.java | 6 +- 7 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/iemr/flw/dto/iemr/CompletedBeneficiariesDTO.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/FormResponseStatus.java diff --git a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java index 99ebb9f7..33ac46b2 100644 --- a/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java +++ b/src/main/java/com/iemr/flw/controller/DynamicFormResponseController.java @@ -21,6 +21,7 @@ */ package com.iemr.flw.controller; +import com.iemr.flw.dto.iemr.CompletedBeneficiariesDTO; import com.iemr.flw.dto.iemr.FormResponseDTO; import com.iemr.flw.dto.iemr.FormResponseRequest; import com.iemr.flw.masterEnum.FormType; @@ -98,13 +99,14 @@ public ResponseEntity getById(@RequestParam Long responseId) { FormResponseDTO dto = responseService.getResponseById(responseId); return ResponseEntity.ok(new ApiResponse(true, "Response fetched successfully", dto)); } - @Operation(summary = "Get beneficiary IDs that have a COMPLETE form response for the given form type, optionally filtered by village and/or provider service map") + @Operation(summary = "Get beneficiary IDs that have a COMPLETE or REFUSED form response for the given form type, optionally filtered by village and/or provider service map") @RequestMapping(value = "/getCompletedBeneficiaries", method = RequestMethod.GET) public ResponseEntity getCompletedBeneficiaries( @RequestParam FormType formType, @RequestParam(required = false) Integer villageId, @RequestParam(required = false) Integer providerServiceMapId) { - List beneficiaryIds = responseService.getCompletedBeneficiaries(formType, villageId, providerServiceMapId); - return ResponseEntity.ok(new ApiResponse(true, "Completed beneficiaries fetched successfully", beneficiaryIds)); + CompletedBeneficiariesDTO result = + responseService.getCompletedBeneficiaries(formType, villageId, providerServiceMapId); + return ResponseEntity.ok(new ApiResponse(true, "Completed beneficiaries fetched successfully", result)); } } diff --git a/src/main/java/com/iemr/flw/dto/iemr/CompletedBeneficiariesDTO.java b/src/main/java/com/iemr/flw/dto/iemr/CompletedBeneficiariesDTO.java new file mode 100644 index 00000000..b391cff0 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/iemr/CompletedBeneficiariesDTO.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.flw.dto.iemr; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Beneficiary IDs for a form type, split by outcome: those who completed the form and those + * who refused (TB2_GI_Q1 = NO on the General Info consent question). + * + * @author Piramal Swasthya + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CompletedBeneficiariesDTO { + private List completed; + private List refused; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/masterEnum/FormResponseStatus.java b/src/main/java/com/iemr/flw/masterEnum/FormResponseStatus.java new file mode 100644 index 00000000..62fffd00 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/FormResponseStatus.java @@ -0,0 +1,36 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.masterEnum; + +/** + * Lifecycle status stored in FormResponse.status. + * + * @author Piramal Swasthya + */ +public enum FormResponseStatus { + /** Saved via /submit. */ + SUBMITTED, + /** Saved via /complete, consent question answered YES (or not applicable). */ + COMPLETE, + /** Saved via /complete, consent question TB2_GI_Q1 answered NO. */ + REFUSED +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java b/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java index 59690f5b..0173db59 100644 --- a/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java +++ b/src/main/java/com/iemr/flw/seeder/TbCounsellingV2FormSeeder.java @@ -98,7 +98,7 @@ private DynamicFormDTO buildFormDto() { private FormSectionDTO buildGeneralInfoSection() { FormSectionDTO s = section("TB2_SEC_GENERAL_INFO", "General Information", "सामान्य जानकारी", - SectionPhase.GENERAL_INFO, 1, true, false, false); + SectionPhase.GENERAL_INFO, 1, true, false, true); List qs = new ArrayList<>(); // "Has the beneficiary agreed for counselling?" — RADIO: Yes | No diff --git a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java index c6929e62..61a3bce1 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormResponseService.java @@ -21,6 +21,7 @@ */ package com.iemr.flw.service; +import com.iemr.flw.dto.iemr.CompletedBeneficiariesDTO; import com.iemr.flw.dto.iemr.FormResponseDTO; import com.iemr.flw.dto.iemr.FormResponseRequest; import com.iemr.flw.masterEnum.FormType; @@ -61,6 +62,9 @@ public interface DynamicFormResponseService { */ List findPendingFollowUps(List formIds, int delayDays); - /** Returns beneficiary IDs with COMPLETE status for the given form type, optionally filtered by village and/or provider service map. */ - List getCompletedBeneficiaries(FormType formType, Integer villageId, Integer providerServiceMapId); + /** + * Returns beneficiary IDs for the given form type split by outcome — COMPLETE and REFUSED — + * optionally filtered by village and/or provider service map. + */ + CompletedBeneficiariesDTO getCompletedBeneficiaries(FormType formType, Integer villageId, Integer providerServiceMapId); } diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index 0bcd49df..4c1c3c4d 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -28,6 +28,7 @@ import com.iemr.flw.domain.iemr.QuestionResponse; import com.iemr.flw.domain.iemr.SectionQuestion; import com.iemr.flw.domain.iemr.SectionResponse; +import com.iemr.flw.dto.iemr.CompletedBeneficiariesDTO; import com.iemr.flw.dto.iemr.FormResponseDTO; import com.iemr.flw.dto.iemr.FormResponseRequest; import com.iemr.flw.dto.iemr.QuestionAnswerRequest; @@ -35,6 +36,7 @@ import com.iemr.flw.dto.iemr.SectionAnswerRequest; import com.iemr.flw.dto.iemr.SectionResponseDTO; import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.masterEnum.FormResponseStatus; import com.iemr.flw.masterEnum.FormType; import com.iemr.flw.repo.iemr.DynamicFormRepo; import com.iemr.flw.repo.iemr.FormResponseRepo; @@ -76,10 +78,11 @@ @Service public class DynamicFormResponseServiceImpl implements DynamicFormResponseService { - private static final String STATUS_DRAFT = "DRAFT"; - private static final String STATUS_SUBMITTED = "SUBMITTED"; - private static final String STATUS_COMPLETE = "COMPLETE"; private static final String SECTION_STATUS_DONE = "DONE"; + private static final String SECTION_STATUS_REFUSED = "REFUSED"; + private static final String SECTION_UUID_GENERAL_INFO = "TB2_SEC_GENERAL_INFO"; + private static final String QUESTION_UUID_CONSENT = "TB2_GI_Q1"; + private static final String OPTION_VALUE_CONSENT_REFUSED = "NO"; private final DynamicFormRepo dynamicFormRepo; private final FormResponseRepo formResponseRepo; @@ -103,19 +106,19 @@ public FormResponseDTO submitForm(FormResponseRequest request) { formResponse = formResponseRepo.findById(request.getResponseId()) .orElseThrow(() -> new NoSuchElementException( "FormResponse not found: " + request.getResponseId())); - if (STATUS_COMPLETE.equals(formResponse.getStatus())) { + if (FormResponseStatus.COMPLETE.name().equals(formResponse.getStatus())) { throw new IllegalStateException( "Cannot update a COMPLETE response (responseId=" + request.getResponseId() + ")"); } // Bump submittedAt to reflect the re-submission time - formResponse.setStatus(STATUS_SUBMITTED); + formResponse.setStatus(FormResponseStatus.SUBMITTED.name()); formResponse.setSubmittedAt(now); formResponse.setLastFollowUpAt(now); formResponse = formResponseRepo.save(formResponse); } else { - formResponse = createFormResponse(request, version, STATUS_SUBMITTED, now); + formResponse = createFormResponse(request, version, FormResponseStatus.SUBMITTED.name(), now); } - return processSections(formResponse, request, version, null); + return processSections(formResponse, request, version, null, false); } @Override @@ -130,14 +133,16 @@ public FormResponseDTO completeForm(FormResponseRequest request, String jwtToken List existing = formResponseRepo.findByBeneficiaryIdAndFormId(request.getBeneficiaryId(), formId); FormResponse formResponse = existing.isEmpty() - ? createFormResponse(request, version, STATUS_SUBMITTED, now) + ? createFormResponse(request, version, FormResponseStatus.SUBMITTED.name(), now) : existing.get(0); formResponse.setUpdatedBy(actor); - formResponse.setStatus(STATUS_COMPLETE); + formResponse.setStatus(isConsentRefused(request) + ? FormResponseStatus.REFUSED.name() + : FormResponseStatus.COMPLETE.name()); formResponse.setCompletedAt(now); formResponse = formResponseRepo.save(formResponse); - return processSections(formResponse, request, version, actor); + return processSections(formResponse, request, version, actor, true); } @Override @@ -198,7 +203,8 @@ private FormResponseDTO processSections( FormResponse formResponse, FormResponseRequest request, FormVersion version, - String actor) { + String actor, + boolean applyRefusalRule) { List allSections = formSectionRepo.findByFormVersion_VersionIdOrderByDisplayOrderAsc(version.getVersionId()); @@ -241,8 +247,11 @@ private FormResponseDTO processSections( "' not found in form '" + request.getFormUuid() + "'"); } + String desiredStatus = applyRefusalRule + ? determineSectionStatus(section, sectionReq.getAnswers()) + : SECTION_STATUS_DONE; Optional sectionResponseOpt = upsertSectionResponse( - formResponse.getResponseId(), section, actor); + formResponse.getResponseId(), section, actor, desiredStatus); if (sectionResponseOpt.isEmpty()) { continue; } @@ -271,7 +280,8 @@ private FormResponseDTO processSections( return buildFormResponseDTO(formResponse, sectionDTOs); } - private Optional upsertSectionResponse(Long responseId, FormSection section, String actor) { + private Optional upsertSectionResponse( + Long responseId, FormSection section, String actor, String status) { Optional existing = sectionResponseRepo.findByResponseIdAndSectionId(responseId, section.getSectionId()); if (existing.isPresent()) { @@ -281,7 +291,7 @@ private Optional upsertSectionResponse(Long responseId, FormSec return Optional.empty(); } SectionResponse sr = existing.get(); - sr.setStatus(SECTION_STATUS_DONE); + sr.setStatus(status); sr.setSavedAt(new Timestamp(System.currentTimeMillis())); sr.setUpdatedBy(actor); return Optional.of(sectionResponseRepo.save(sr)); @@ -289,7 +299,7 @@ private Optional upsertSectionResponse(Long responseId, FormSec SectionResponse sr = SectionResponse.builder() .responseId(responseId) .sectionId(section.getSectionId()) - .status(SECTION_STATUS_DONE) + .status(status) .savedAt(new Timestamp(System.currentTimeMillis())) .createdBy(actor) .updatedBy(actor) @@ -297,6 +307,38 @@ private Optional upsertSectionResponse(Long responseId, FormSec return Optional.of(sectionResponseRepo.save(sr)); } + // TB2_SEC_GENERAL_INFO's consent question (TB2_GI_Q1) gates the rest of the TB Counselling + // form; a "NO" answer means the beneficiary refused counselling. Absence-tolerant by design: + // any other section, a missing answers list, or no answer for the consent question at all + // just falls through to the normal DONE status. + private String determineSectionStatus(FormSection section, List answers) { + if (!SECTION_UUID_GENERAL_INFO.equals(section.getSectionUuid()) || answers == null) { + return SECTION_STATUS_DONE; + } + boolean refused = answers.stream().anyMatch(this::isRefusalAnswer); + return refused ? SECTION_STATUS_REFUSED : SECTION_STATUS_DONE; + } + + // Same TB2_GI_Q1="NO" signal as determineSectionStatus, but scanned directly off the raw + // request (before any FormSection is resolved) so completeForm() can decide the top-level + // FormResponse status. Absence-tolerant: no TB2_SEC_GENERAL_INFO section, no answers, or no + // TB2_GI_Q1 answer at all just means "not refused". + private boolean isConsentRefused(FormResponseRequest request) { + if (request.getSections() == null) { + return false; + } + return request.getSections().stream() + .filter(s -> SECTION_UUID_GENERAL_INFO.equals(s.getSectionUuid())) + .filter(s -> s.getAnswers() != null) + .flatMap(s -> s.getAnswers().stream()) + .anyMatch(this::isRefusalAnswer); + } + + private boolean isRefusalAnswer(QuestionAnswerRequest answer) { + return QUESTION_UUID_CONSENT.equals(answer.getQuestionUuid()) + && OPTION_VALUE_CONSENT_REFUSED.equals(answer.getOptionValue()); + } + private List processAnswers( Long sectionResponseId, List answers, @@ -486,12 +528,21 @@ public List findPendingFollowUps(List formIds, int delayD @Override @Transactional(readOnly = true) - public List getCompletedBeneficiaries(FormType formType, Integer villageId, Integer providerServiceMapId) { + public CompletedBeneficiariesDTO getCompletedBeneficiaries( + FormType formType, Integer villageId, Integer providerServiceMapId) { DynamicForm form = dynamicFormRepo.findByFormTypeAndIsActive(formType, true) .orElseThrow(() -> new NoSuchElementException( "No active form found for type: " + formType)); + return CompletedBeneficiariesDTO.builder() + .completed(findBeneficiaryIdsByStatus(form.getFormId(), FormResponseStatus.COMPLETE.name(), villageId, providerServiceMapId)) + .refused(findBeneficiaryIdsByStatus(form.getFormId(), FormResponseStatus.REFUSED.name(), villageId, providerServiceMapId)) + .build(); + } + + private List findBeneficiaryIdsByStatus( + Long formId, String status, Integer villageId, Integer providerServiceMapId) { return (villageId != null || providerServiceMapId != null) - ? formResponseRepo.findBeneficiaryIdsByFormIdAndStatusFiltered(form.getFormId(), STATUS_COMPLETE, villageId, providerServiceMapId) - : formResponseRepo.findBeneficiaryIdsByFormIdAndStatus(form.getFormId(), STATUS_COMPLETE); + ? formResponseRepo.findBeneficiaryIdsByFormIdAndStatusFiltered(formId, status, villageId, providerServiceMapId) + : formResponseRepo.findBeneficiaryIdsByFormIdAndStatus(formId, status); } } diff --git a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java index 016681f3..7b965223 100644 --- a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java +++ b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java @@ -41,6 +41,7 @@ import com.iemr.flw.repo.iemr.QuestionResponseRepo; import com.iemr.flw.repo.iemr.SectionQuestionRepo; import com.iemr.flw.repo.iemr.SectionResponseRepo; +import com.iemr.flw.masterEnum.FormResponseStatus; import com.iemr.flw.masterEnum.QuestionType; import com.iemr.flw.masterEnum.SectionPhase; import com.iemr.flw.utils.JwtUtil; @@ -73,7 +74,6 @@ @Component public class FormResponseItemSaver { - private static final String STATUS_SUBMITTED = "SUBMITTED"; private static final String SECTION_STATUS_DONE = "DONE"; private final FormResponseRepo formResponseRepo; @@ -110,7 +110,7 @@ public FormResponseDTO saveForBulk(FormResponseRequest req, String jwtToken) thr Long responseId = formResponse.getResponseId(); // Step 3: Update FormResponse header - formResponse.setStatus(STATUS_SUBMITTED); + formResponse.setStatus(FormResponseStatus.SUBMITTED.name()); formResponse.setSubmittedAt(now); formResponse.setUpdatedBy(actor); formResponse.setLastFollowUpAt(now); @@ -176,7 +176,7 @@ private FormResponse createFormResponse(FormResponseRequest req, FormVersion ver .formId(version.getDynamicForm().getFormId()) .versionId(version.getVersionId()) .officerId(req.getOfficerId()) - .status(STATUS_SUBMITTED) + .status(FormResponseStatus.SUBMITTED.name()) .createdBy(actor) .updatedBy(actor) .submittedAt(now) From b4bc4e07d1c006a6c96edb012742d7aa22934a86 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 7 Jul 2026 10:52:53 +0530 Subject: [PATCH 19/47] Resolved conflicts --- StopTB_Standard_Table_Integration.md | 294 ++++++++++++++++++ .../domain/iemr/BenAnthropometryDetail.java | 70 +++++ .../flw/domain/iemr/BenChiefComplaint.java | 73 +++++ .../domain/iemr/BenPhysicalVitalDetail.java | 76 +++++ .../domain/iemr/PhyGeneralExamination.java | 79 +++++ .../flw/domain/iemr/PrescribedDrugDetail.java | 82 +++++ .../flw/domain/iemr/PrescriptionDetail.java | 70 +++++ .../com/iemr/flw/domain/iemr/TBStopVisit.java | 77 ----- .../flw/repo/iemr/BenAnthropometryRepo.java | 9 + .../flw/repo/iemr/BenChiefComplaintRepo.java | 9 + .../flw/repo/iemr/BenPhysicalVitalRepo.java | 9 + .../flw/repo/iemr/BenVisitDetailsRepo.java | 18 +- .../repo/iemr/PhyGeneralExaminationRepo.java | 9 + .../repo/iemr/PrescribedDrugDetailRepo.java | 9 + .../flw/repo/iemr/PrescriptionDetailRepo.java | 9 + .../iemr/flw/repo/iemr/TBStopVisitRepo.java | 29 -- .../iemr/flw/service/TBStopVisitService.java | 12 +- .../impl/DynamicFormResponseServiceImpl.java | 46 ++- .../service/impl/FormResponseItemSaver.java | 38 ++- .../flw/service/impl/StopTBServiceImpl.java | 250 ++++++++++++++- .../impl/TBConfirmedCaseServiceImpl.java | 8 +- .../service/impl/TBStopVisitServiceImpl.java | 59 ++-- .../service/impl/TBSuspectedServiceImpl.java | 8 +- 23 files changed, 1165 insertions(+), 178 deletions(-) create mode 100644 StopTB_Standard_Table_Integration.md create mode 100644 src/main/java/com/iemr/flw/domain/iemr/BenAnthropometryDetail.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/BenChiefComplaint.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/BenPhysicalVitalDetail.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/PhyGeneralExamination.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/PrescribedDrugDetail.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/PrescriptionDetail.java delete mode 100644 src/main/java/com/iemr/flw/domain/iemr/TBStopVisit.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/BenAnthropometryRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/BenChiefComplaintRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/BenPhysicalVitalRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/PhyGeneralExaminationRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/PrescribedDrugDetailRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/PrescriptionDetailRepo.java delete mode 100644 src/main/java/com/iemr/flw/repo/iemr/TBStopVisitRepo.java diff --git a/StopTB_Standard_Table_Integration.md b/StopTB_Standard_Table_Integration.md new file mode 100644 index 00000000..9df8b37d --- /dev/null +++ b/StopTB_Standard_Table_Integration.md @@ -0,0 +1,294 @@ +# Stop TB — Standard Table Integration + +**Branch:** `vb/counselling` +**Date:** 2026-06-30 +**Author:** vishwab1 + +--- + +## 1. Why this was done + +Stop TB's clinical data was siloed entirely inside custom `tb_stoptb_*` tables. No other AMRIT +service (HWC, MMU) could see a Stop TB beneficiary's record — referrals, vitals, prescriptions +were invisible across service lines. + +This integration dual-writes Stop TB clinical data into the same standard tables that MMU/HWC +already use, making beneficiary records portable across services without any change to the +mobile app. + +--- + +## 2. What was implemented + +### 2.1 Visit layer — `t_benvisitdetail` + +**Problem:** Stop TB used its own `tb_stoptb_visit` mirror table. `benVisitID` was null in all +clinical tables. HWC/MMU had no visit anchor to join on. + +**Fix:** `TBStopVisitServiceImpl` was completely rewritten to create rows in `t_benvisitdetail` +instead of `tb_stoptb_visit`. The old entity and repo were deleted. + +**Key behaviour (get-or-create-for-today):** +- First save of the day → creates a new `t_benvisitdetail` row +- Subsequent saves same day → finds and reuses the same row +- Next camp day → creates a new row (separate visit, history preserved) + +**VisitCode formula (same as MMU):** +``` +visitCode = "1" + vanID(zero-padded 5 digits) + benVisitId(zero-padded 8 digits) +Example: vanID=1, benVisitId=42 → 10000100000042 +``` + +**Files changed:** +- `TBStopVisitService.java` — interface return type changed to `BenVisitDetail` +- `TBStopVisitServiceImpl.java` — full rewrite, uses `BenVisitDetailsRepo` +- `BenVisitDetailsRepo.java` — added `findStopTBVisitForToday()` and `countStopTBVisits()` +- `TBStopVisit.java` — **DELETED** (orphaned) +- `TBStopVisitRepo.java` — **DELETED** (orphaned) + +--- + +### 2.2 Standard table dual-writes + +All dual-writes happen inside `StopTBServiceImpl`. Custom `tb_stoptb_*` tables continue to +be written unchanged. Standard table writes are **additive and insert-only** (no edit +capability exists in the mobile). + +Each write is wrapped in its own try/catch — a standard-table failure never breaks the +primary Stop TB save. + +#### `saveGeneralExamination()` writes to: + +| Standard Table | Data source | Fields written | +|---|---|---| +| `t_phy_anthropometry` | `i_beneficiarydetails.otherFields` (registration) | `Height_cm`, `Weight_Kg`, `BMI` | +| `t_phy_vitals` | otherFields + request | `Temperature` (from otherFields), `PulseRate`, `SystolicBP_1stReading`, `DiastolicBP_1stReading`, `BloodGlucose_Random` | +| `t_Phy_GeneralExam` | request | `Pallor`, `Jaundice` (←icterus), `Cyanosis`, `Clubbing`, `Lymphadenopathy`, `Edema` (←oedema) | +| `t_benreferdetails` | request | written only when `referralToHWCNeeded = true` | + +> **Note:** Height/weight/bmi/temperature come from registration `otherFields` because the +> mobile's `GeneralExaminationSaveRequest` does not include these fields. The mobile hardcodes +> them null in `VitalScreenFragment.saveVitals()`. They are captured at registration and synced +> to Identity-API via TM-API. + +#### `saveGeneralOpd()` writes to: + +| Standard Table | Fields written | +|---|---| +| `t_benchiefcomplaint` | `ChiefComplaint` (free text from request) | +| `t_prescription` | `Instruction` (from `notes` field) | +| `t_prescribeddrug` | `GenericDrugName` (medication), `Dose`, `Frequency`, `Duration`, linked to `PrescriptionID` | + +All 7 standard tables share the same `benVisitID` and `VisitCode` as the join key. + +--- + +### 2.3 New entities and repos created + +All in package `com.iemr.flw.domain.iemr` / `com.iemr.flw.repo.iemr`: + +| Entity | Table | Repo | +|---|---|---| +| `BenAnthropometryDetail` | `t_phy_anthropometry` | `BenAnthropometryRepo` | +| `BenPhysicalVitalDetail` | `t_phy_vitals` | `BenPhysicalVitalRepo` | +| `PhyGeneralExamination` | `t_Phy_GeneralExam` | `PhyGeneralExaminationRepo` | +| `BenChiefComplaint` | `t_benchiefcomplaint` | `BenChiefComplaintRepo` | +| `PrescriptionDetail` | `t_prescription` | `PrescriptionDetailRepo` | +| `PrescribedDrugDetail` | `t_prescribeddrug` | `PrescribedDrugDetailRepo` | + +All 6 standard tables already existed in `db_iemr`. No CREATE TABLE needed for these. +`BenReferDetails` and `BenReferDetailsRepo` already existed in FLW-API — just wired up. + +--- + +### 2.4 Service files updated + +All Stop TB service files that previously used `TBStopVisit` and `visit.getId()` were updated +to use `BenVisitDetail` and `visit.getVisitCode()`: + +| File | Change | +|---|---| +| `StopTBServiceImpl` | `saveGeneralExamination`, `saveNurseTBScreening`, `saveGeneralOpd` — all updated | +| `TBSuspectedServiceImpl` | `save()` — updated | +| `TBConfirmedCaseServiceImpl` | `save()` — updated | + +The `visitCode` column on `tb_stoptb_suspected` and `tb_stoptb_confirmed_cases` is still used +for per-visit keying. The value written is now the 14-digit standard visitCode instead of the +old `tb_stoptb_visit` PK. + +--- + +## 3. DB migration required + +Run these two statements on the server. The standard tables (`t_phy_anthropometry` etc.) +already exist in `db_iemr` — no CREATE needed for them. + +```sql +-- Required: visitCode column added to these two tables when per-visit history was introduced +ALTER TABLE tb_stoptb_suspected ADD COLUMN visitCode BIGINT; +ALTER TABLE tb_stoptb_confirmed_cases ADD COLUMN visitCode BIGINT; + +-- DO NOT run: tb_stoptb_visit is replaced by t_benvisitdetail +-- CREATE TABLE tb_stoptb_visit ← SKIP THIS +``` + +--- + +## 4. BenFlowStatus (`i_ben_flow_outreach`) — current state + +| Flag | Set when | Value | +|---|---|---| +| `nurseFlag` | Registration | `1` (pending for nurse) | +| `nurseFlag` | `submitNurseData()` | `9` (nurse done) | +| `doctorFlag` | `routeToStopTBCounsellor()` | `1` (pending for counsellor) | + +**Known gap:** Mobile does not call `flw-api/stoptb/nurse/submit` (app is live, cannot change). +So `nurseFlag` stays at `1` forever after registration. + +**Why this is not breaking:** The mobile also does not call the nurse worklist or registrar +worklist endpoints — both are commented out in `AmritApiService.kt`. The mobile uses +`flw-api/beneficiary/getBeneficiaryData` instead and manages its own local state. + +**Counsellor:** Finds confirmed TB cases via direct `tb_confirmed_cases` query, not via +`doctorFlag`. This works correctly today. + +**Future action (when web dashboard is built):** Wire up `nurseFlag` advancement by checking +all 3 mandatory nurse saves (generalExamination + tbScreening + generalOpd) exist for the +same `visitCode`, then auto-advance `nurseFlag = 9`. The query +`updateStopTBAfterNurseSubmit()` in `BenFlowStatusRepo` already exists for this. + +--- + +## 5. Complete mobile API list (live, as of 2026-06-30) + +### Auth +| Endpoint | +|---| +| `POST common-api/user/userAuthenticate` | +| `POST common-api/user/refreshToken` | +| `GET flw-api/user/getUserDetail` | +| `POST common-api/firebaseNotification/userToken` | + +### Beneficiary Registration +| Endpoint | +|---| +| `POST tm-api/registrar/registrarBeneficaryRegistrationNew` | +| `POST identity-api/rmnch/syncDataToAmrit` | +| `POST flw-api/beneficiary/getBeneficiaryData` | + +### Consent / OTP +| Endpoint | +|---| +| `POST common-api/beneficiaryConsent/sendConsent` | +| `POST common-api/beneficiaryConsent/resendConsent` | +| `POST common-api/beneficiaryConsent/validateConsent` | + +### Stop TB — Nurse +| Endpoint | +|---| +| `POST flw-api/stoptb/nurse/generalExamination/save` | +| `POST flw-api/stoptb/nurse/generalExamination/getAll` | +| `POST flw-api/stoptb/nurse/tbScreening/save` | +| `POST flw-api/stoptb/nurse/tbScreening/getAll` | +| `POST flw-api/stoptb/nurse/generalOpd/save` | +| `POST flw-api/stoptb/nurse/generalOpd/getAll` | +| `POST flw-api/stoptb/nurse/diagnostics/save` | +| `POST flw-api/stoptb/nurse/diagnostics/getAll` | + +### Stop TB — Suspected / Confirmed +| Endpoint | +|---| +| `POST flw-api/tb/suspected/saveAll` | +| `POST flw-api/tb/suspected/getAll` | +| `POST flw-api/tb/confirmed/save` | +| `GET flw-api/tb/confirmed/getAll` | + +### Counselling (Dynamic Form) +| Endpoint | +|---| +| `GET flw-api/dynamicForm/getAllForms` | +| `GET common-api/dynamicForm/form/{formId}/fields` | +| `POST flw-api/counselling/save` | +| `POST flw-api/dynamicForm/response/submitBulk` | +| `POST flw-api/dynamicForm/response/complete` | +| `GET flw-api/dynamicForm/response/getByBeneficiary` | +| `GET flw-api/dynamicForm/response/getCompletedBeneficiaries` | + +### CBAC / NCD +| Endpoint | +|---| +| `POST hwc-api/NCD/getByUserCbacDetails` | +| `POST hwc-api/NCD/save/nurseData` | +| `POST hwc-api/NCD/save/referDetails` | + +### ABHA / Health ID +| Endpoint | +|---| +| `POST fhir-api/healthIDWithUID/createHealthIDWithUID` | +| `POST fhir-api/healthID/getBenhealthID` | +| `POST fhir-api/healthIDRecord/mapHealthIDToBeneficiary` | +| `POST fhir-api/healthIDRecord/addHealthIdRecord` | +| `POST fhir-api/healthIDCard/generateOTP` | +| `POST fhir-api/healthIDCard/verifyOTPAndGenerateHealthCard` | + +### Other Disease Screens +| Endpoint | +|---| +| `POST flw-api/disease/kalaAzar/saveAll` | +| `POST flw-api/disease/malaria/saveAll` | +| `POST flw-api/disease/leprosy/saveAll` | +| `POST flw-api/disease/leprosy/followUp/saveAll` | +| `POST flw-api/disease/leprosy/followUp/getAll` | +| `POST flw-api/disease/aesJe/saveAll` | +| `POST flw-api/disease/filaria/saveAll` | +| `POST flw-api/disease/getAllDisease` | +| `POST flw-api/follow-up/save` | +| `POST flw-api/follow-up/get` | +| `POST flw-api/child-care/hbncVisit/saveAll` | +| `POST flw-api/disease/cdtfVisit/saveAll` | +| `POST flw-api/disease/cdtfVisit/getAll` | + +### Commented out / never called (documented for future reference) +| Endpoint | Reason | +|---|---| +| `POST flw-api/stoptb/nurse/worklist` | Mobile uses getBeneficiaryData instead | +| `POST flw-api/stoptb/registrar/worklist` | Mobile uses getBeneficiaryData instead | +| `POST flw-api/stoptb/nurse/submit` | Mobile is live — cannot add new call; nurseFlag stays 1 (acceptable, see Section 4) | +| `POST hwc-api/common/getBenReferDetailsByCreatedBy` | Referral pull not needed in Stop TB | +| `POST hwc-api/sync/generalOPDNurseFormDataToServer` | Abandoned earlier attempt at standard-table integration | +| `POST identity-api/id/getByBenId` | getBenHealthID used instead | + +--- + +## 6. What HWC can now see + +Once the DB migration SQL is run and FLW-API is deployed, any service reading from `db_iemr` +standard tables will find Stop TB beneficiary data joined by `benVisitID` / `VisitCode`: + +| Table | Content | +|---|---| +| `t_benvisitdetail` | One row per camp day, `visitCategory = 'Stop TB'` | +| `t_phy_anthropometry` | Height, weight, BMI (from registration) | +| `t_phy_vitals` | Temperature (from registration), pulse, BP, RBS (from nurse exam) | +| `t_Phy_GeneralExam` | Pallor, jaundice, cyanosis, clubbing, lymphadenopathy, edema | +| `t_benchiefcomplaint` | Chief complaint text | +| `t_prescription` | Notes/instructions | +| `t_prescribeddrug` | Medication, dose, frequency, duration | +| `t_benreferdetails` | HWC referral (only when `referralToHWCNeeded = true`) | + +> **Note:** HWC's referral-receiving worklist endpoints (`getBenPreviousReferralHistoryDetails`, +> `getBenReferDetailsByCreatedBy`) have zero callers on HWC-UI and HWC-Mobile-App as of today. +> Writing `t_benreferdetails` is correct for audit/reporting. Surfacing it as a live HWC +> worklist is a separate, future piece of work. + +--- + +## 7. What is NOT done (future scope) + +| Item | Detail | +|---|---| +| `nurseFlag` auto-advancement | Needs web dashboard to be useful. Logic: check all 3 saves (exam + screening + OPD) exist for same `visitCode` → set `nurseFlag=9`. Query already exists in `BenFlowStatusRepo.updateStopTBAfterNurseSubmit()` | +| HWC worklist for Stop TB referrals | HWC-API and HWC-UI changes needed to surface `t_benreferdetails` rows | +| Duplicate beneficiary detection | Requires new mobile screen — no server-side gap | +| Beneficiary edit/update | Requires new mobile screen — no edit endpoint exists anywhere in Stop TB | +| `height`/`weight`/`bmi`/`respiratoryRate`/`spo2` in nurse exam | Mobile captures these locally (`VitalCache`) but `GeneralExaminationSaveRequest.from()` hardcodes them null. Would require mobile change to send live | diff --git a/src/main/java/com/iemr/flw/domain/iemr/BenAnthropometryDetail.java b/src/main/java/com/iemr/flw/domain/iemr/BenAnthropometryDetail.java new file mode 100644 index 00000000..777e8011 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/BenAnthropometryDetail.java @@ -0,0 +1,70 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import java.sql.Timestamp; + +@Entity +@Table(name = "t_phy_anthropometry", schema = "db_iemr") +@Data +public class BenAnthropometryDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "BeneficiaryRegID") + private Long beneficiaryRegID; + + @Column(name = "BenVisitID") + private Long benVisitID; + + @Column(name = "ProviderServiceMapID") + private Integer providerServiceMapID; + + @Column(name = "VisitCode") + private Long visitCode; + + @Column(name = "Weight_Kg") + private Double weightKg; + + @Column(name = "Height_cm") + private Double heightCm; + + @Column(name = "BMI") + private Double bmi; + + @Column(name = "Deleted", insertable = false, updatable = true) + private Boolean deleted; + + @Column(name = "Processed", insertable = false, updatable = true) + private String processed; + + @Column(name = "CreatedBy") + private String createdBy; + + @Column(name = "CreatedDate", insertable = false, updatable = false) + private Timestamp createdDate; + + @Column(name = "ModifiedBy") + private String modifiedBy; + + @Column(name = "LastModDate", insertable = false, updatable = false) + private Timestamp lastModDate; + + @Column(name = "VanSerialNo") + private Long vanSerialNo; + + @Column(name = "VanID") + private Integer vanID; + + @Column(name = "ParkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "SyncedBy") + private String syncedBy; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/BenChiefComplaint.java b/src/main/java/com/iemr/flw/domain/iemr/BenChiefComplaint.java new file mode 100644 index 00000000..3b016c5f --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/BenChiefComplaint.java @@ -0,0 +1,73 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import java.sql.Timestamp; + +@Entity +@Table(name = "t_benchiefcomplaint", schema = "db_iemr") +@Data +public class BenChiefComplaint { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "BeneficiaryRegID") + private Long beneficiaryRegID; + + @Column(name = "BenVisitID") + private Long benVisitID; + + @Column(name = "ProviderServiceMapID") + private Integer providerServiceMapID; + + @Column(name = "VisitCode") + private Long visitCode; + + @Column(name = "ChiefComplaint") + private String chiefComplaint; + + @Column(name = "Duration") + private Integer duration; + + @Column(name = "UnitOfDuration") + private String unitOfDuration; + + @Column(name = "Description") + private String description; + + @Column(name = "Deleted", insertable = false, updatable = true) + private Boolean deleted; + + @Column(name = "Processed", insertable = false, updatable = true) + private String processed; + + @Column(name = "CreatedBy") + private String createdBy; + + @Column(name = "CreatedDate", insertable = false, updatable = false) + private Timestamp createdDate; + + @Column(name = "ModifiedBy") + private String modifiedBy; + + @Column(name = "LastModDate", insertable = false, updatable = false) + private Timestamp lastModDate; + + @Column(name = "VanSerialNo") + private Long vanSerialNo; + + @Column(name = "VanID") + private Integer vanID; + + @Column(name = "ParkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "SyncedBy") + private String syncedBy; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/BenPhysicalVitalDetail.java b/src/main/java/com/iemr/flw/domain/iemr/BenPhysicalVitalDetail.java new file mode 100644 index 00000000..15b03aee --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/BenPhysicalVitalDetail.java @@ -0,0 +1,76 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import java.sql.Timestamp; + +@Entity +@Table(name = "t_phy_vitals", schema = "db_iemr") +@Data +public class BenPhysicalVitalDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "BeneficiaryRegID") + private Long beneficiaryRegID; + + @Column(name = "BenVisitID") + private Long benVisitID; + + @Column(name = "ProviderServiceMapID") + private Integer providerServiceMapID; + + @Column(name = "VisitCode") + private Long visitCode; + + @Column(name = "Temperature") + private Double temperature; + + @Column(name = "PulseRate") + private Short pulseRate; + + @Column(name = "SystolicBP_1stReading") + private Short systolicBP; + + @Column(name = "DiastolicBP_1stReading") + private Short diastolicBP; + + @Column(name = "BloodGlucose_Random") + private Short bloodGlucoseRandom; + + @Column(name = "Deleted", insertable = false, updatable = true) + private Boolean deleted; + + @Column(name = "Processed", insertable = false, updatable = true) + private String processed; + + @Column(name = "CreatedBy") + private String createdBy; + + @Column(name = "CreatedDate", insertable = false, updatable = false) + private Timestamp createdDate; + + @Column(name = "ModifiedBy") + private String modifiedBy; + + @Column(name = "LastModDate", insertable = false, updatable = false) + private Timestamp lastModDate; + + @Column(name = "VanSerialNo") + private Long vanSerialNo; + + @Column(name = "VanID") + private Integer vanID; + + @Column(name = "ParkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "SyncedBy") + private String syncedBy; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/PhyGeneralExamination.java b/src/main/java/com/iemr/flw/domain/iemr/PhyGeneralExamination.java new file mode 100644 index 00000000..b99fd804 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/PhyGeneralExamination.java @@ -0,0 +1,79 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import java.sql.Timestamp; + +@Entity +@Table(name = "t_Phy_GeneralExam", schema = "db_iemr") +@Data +public class PhyGeneralExamination { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "BeneficiaryRegID") + private Long beneficiaryRegID; + + @Column(name = "BenVisitID") + private Long benVisitID; + + @Column(name = "ProviderServiceMapID") + private Integer providerServiceMapID; + + @Column(name = "VisitCode") + private Long visitCode; + + @Column(name = "Pallor") + private String pallor; + + @Column(name = "Jaundice") + private String jaundice; + + @Column(name = "Cyanosis") + private String cyanosis; + + @Column(name = "Clubbing") + private String clubbing; + + @Column(name = "Lymphadenopathy") + private String lymphadenopathy; + + @Column(name = "Edema") + private String edema; + + @Column(name = "Deleted", insertable = false, updatable = true) + private Boolean deleted; + + @Column(name = "Processed", insertable = false, updatable = true) + private String processed; + + @Column(name = "CreatedBy") + private String createdBy; + + @Column(name = "CreatedDate", insertable = false, updatable = false) + private Timestamp createdDate; + + @Column(name = "ModifiedBy") + private String modifiedBy; + + @Column(name = "LastModDate", insertable = false, updatable = false) + private Timestamp lastModDate; + + @Column(name = "VanSerialNo") + private Long vanSerialNo; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "ParkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "SyncedBy") + private String syncedBy; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/PrescribedDrugDetail.java b/src/main/java/com/iemr/flw/domain/iemr/PrescribedDrugDetail.java new file mode 100644 index 00000000..9a0a1502 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/PrescribedDrugDetail.java @@ -0,0 +1,82 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import java.sql.Timestamp; + +@Entity +@Table(name = "t_prescribeddrug", schema = "db_iemr") +@Data +public class PrescribedDrugDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "PrescribedDrugID", insertable = false, updatable = false) + private Long id; + + @Column(name = "BeneficiaryRegID") + private Long beneficiaryRegID; + + @Column(name = "BenVisitID") + private Long benVisitID; + + @Column(name = "ProviderServiceMapID") + private Integer providerServiceMapID; + + @Column(name = "VisitCode") + private Long visitCode; + + @Column(name = "PrescriptionID") + private Long prescriptionID; + + @Column(name = "GenericDrugName") + private String drugName; + + @Column(name = "Dose") + private String dose; + + @Column(name = "Frequency") + private String frequency; + + @Column(name = "Duration") + private String duration; + + @Column(name = "DuartionUnit") + private String durationUnit; + + @Column(name = "SpecialInstruction") + private String specialInstruction; + + @Column(name = "Deleted", insertable = false, updatable = true) + private Boolean deleted; + + @Column(name = "Processed", insertable = false, updatable = true) + private String processed; + + @Column(name = "CreatedBy") + private String createdBy; + + @Column(name = "CreatedDate", insertable = false, updatable = false) + private Timestamp createdDate; + + @Column(name = "ModifiedBy") + private String modifiedBy; + + @Column(name = "LastModDate", insertable = false, updatable = false) + private Timestamp lastModDate; + + @Column(name = "VanSerialNo") + private Long vanSerialNo; + + @Column(name = "VanID") + private Integer vanID; + + @Column(name = "ParkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "SyncedBy") + private String syncedBy; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/PrescriptionDetail.java b/src/main/java/com/iemr/flw/domain/iemr/PrescriptionDetail.java new file mode 100644 index 00000000..5bc5295d --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/PrescriptionDetail.java @@ -0,0 +1,70 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import java.sql.Timestamp; + +@Entity +@Table(name = "t_prescription", schema = "db_iemr") +@Data +public class PrescriptionDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "PrescriptionID", insertable = false, updatable = false) + private Long prescriptionID; + + @Column(name = "BeneficiaryRegID") + private Long beneficiaryRegID; + + @Column(name = "BenVisitID") + private Long benVisitID; + + @Column(name = "ProviderServiceMapID") + private Integer providerServiceMapID; + + @Column(name = "VisitCode") + private Long visitCode; + + @Column(name = "DiagnosisProvided") + private String diagnosisProvided; + + @Column(name = "Instruction") + private String instruction; + + @Column(name = "Remarks") + private String remarks; + + @Column(name = "Deleted", insertable = false, updatable = true) + private Boolean deleted; + + @Column(name = "Processed", insertable = false, updatable = true) + private String processed; + + @Column(name = "CreatedBy") + private String createdBy; + + @Column(name = "CreatedDate", insertable = false, updatable = false) + private Timestamp createdDate; + + @Column(name = "ModifiedBy") + private String modifiedBy; + + @Column(name = "LastModDate", insertable = false, updatable = false) + private Timestamp lastModDate; + + @Column(name = "VanSerialNo") + private Long vanSerialNo; + + @Column(name = "VanID") + private Integer vanID; + + @Column(name = "ParkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "SyncedBy") + private String syncedBy; + + @Column(name = "SyncedDate") + private Timestamp syncedDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/TBStopVisit.java b/src/main/java/com/iemr/flw/domain/iemr/TBStopVisit.java deleted file mode 100644 index 8e7f0e20..00000000 --- a/src/main/java/com/iemr/flw/domain/iemr/TBStopVisit.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.iemr.flw.domain.iemr; - -import lombok.Data; -import jakarta.persistence.*; -import org.hibernate.annotations.CreationTimestamp; - -import java.sql.Timestamp; - -/** - * One row per real-world Stop TB camp encounter (a beneficiary seen on a given calendar day). - * Mirrors MMU's t_benvisitdetail (visitNo, insert-only history) but scoped entirely to Stop TB's - * own tables — never written to or read from MMU/HWC's shared visit/outreach infrastructure. - */ -@Entity -@Table(name = "tb_stoptb_visit", schema = "db_iemr") -@Data -public class TBStopVisit { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "beneficiary_reg_id") - private Long beneficiaryRegID; - - @Column(name = "visit_no") - private Integer visitNo; - - @CreationTimestamp - @Column(name = "visit_date", updatable = false) - private Timestamp visitDate; - - @Column(name = "provider_service_map_id") - private Integer providerServiceMapID; - - @Column(name = "created_by") - private String createdBy; - - // Role-handoff flags — only nurseCompletedFlag is populated today (Stop TB has no - // Doctor/Counsellor screens yet). Reserved so a future worklist can query against - // this table without a schema change later. - @Column(name = "nurse_completed_flag") - private String nurseCompletedFlag = "N"; - - @Column(name = "doctor_completed_flag") - private String doctorCompletedFlag = "N"; - - @Column(name = "counsellor_completed_flag") - private String counsellorCompletedFlag = "N"; - - // Sync fields — same vanID/vanSerialNo/processed discipline as every other Stop TB table, - // required so two camp laptops can each generate local visit rows without colliding at - // central (de-duplication key is (vanSerialNo, vanID), not the local auto-increment id). - @Column(name = "vanID") - private Integer vanID; - - @Column(name = "parkingPlaceID") - private Integer parkingPlaceID; - - @Column(name = "processed") - private String processed = "N"; - - @Column(name = "vanSerialNo") - private Long vanSerialNo; - - // Stamped by the generic local-to-central DataSyncRepository job (MMU/HWC/TM-API convention) - // when this row is actually pushed — required for tb_stoptb_visit to be syncable by that - // same generic mechanism without code changes there. - @Column(name = "SyncedDate") - private Timestamp syncedDate; - - @Column(name = "Syncedby") - private String syncedby; - - @Column(name = "SyncFailureReason") - private String syncFailureReason; -} diff --git a/src/main/java/com/iemr/flw/repo/iemr/BenAnthropometryRepo.java b/src/main/java/com/iemr/flw/repo/iemr/BenAnthropometryRepo.java new file mode 100644 index 00000000..b7c9efd3 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/BenAnthropometryRepo.java @@ -0,0 +1,9 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.BenAnthropometryDetail; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BenAnthropometryRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/BenChiefComplaintRepo.java b/src/main/java/com/iemr/flw/repo/iemr/BenChiefComplaintRepo.java new file mode 100644 index 00000000..47a73f98 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/BenChiefComplaintRepo.java @@ -0,0 +1,9 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.BenChiefComplaint; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BenChiefComplaintRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/BenPhysicalVitalRepo.java b/src/main/java/com/iemr/flw/repo/iemr/BenPhysicalVitalRepo.java new file mode 100644 index 00000000..cf1ed35b --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/BenPhysicalVitalRepo.java @@ -0,0 +1,9 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.BenPhysicalVitalDetail; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BenPhysicalVitalRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/BenVisitDetailsRepo.java b/src/main/java/com/iemr/flw/repo/iemr/BenVisitDetailsRepo.java index dfd605ef..47aacb2b 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/BenVisitDetailsRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/BenVisitDetailsRepo.java @@ -2,10 +2,26 @@ import com.iemr.flw.domain.iemr.BenVisitDetail; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.sql.Timestamp; import java.util.List; @Repository public interface BenVisitDetailsRepo extends JpaRepository { - public List findByBeneficiaryRegId(Long benRegID);} + + List findByBeneficiaryRegId(Long benRegID); + + @Query("SELECT v FROM BenVisitDetail v WHERE v.beneficiaryRegId = :benRegID " + + "AND v.visitCategory = 'Stop TB' " + + "AND v.visitDateTime >= :dayStart AND v.visitDateTime < :dayEnd") + BenVisitDetail findStopTBVisitForToday(@Param("benRegID") Long benRegID, + @Param("dayStart") Timestamp dayStart, + @Param("dayEnd") Timestamp dayEnd); + + @Query("SELECT COUNT(v) FROM BenVisitDetail v WHERE v.beneficiaryRegId = :benRegID " + + "AND v.visitCategory = 'Stop TB'") + Integer countStopTBVisits(@Param("benRegID") Long benRegID); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/PhyGeneralExaminationRepo.java b/src/main/java/com/iemr/flw/repo/iemr/PhyGeneralExaminationRepo.java new file mode 100644 index 00000000..abd73b0b --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/PhyGeneralExaminationRepo.java @@ -0,0 +1,9 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.PhyGeneralExamination; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PhyGeneralExaminationRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/PrescribedDrugDetailRepo.java b/src/main/java/com/iemr/flw/repo/iemr/PrescribedDrugDetailRepo.java new file mode 100644 index 00000000..bcdd8ab5 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/PrescribedDrugDetailRepo.java @@ -0,0 +1,9 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.PrescribedDrugDetail; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PrescribedDrugDetailRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/PrescriptionDetailRepo.java b/src/main/java/com/iemr/flw/repo/iemr/PrescriptionDetailRepo.java new file mode 100644 index 00000000..c0d425ec --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/PrescriptionDetailRepo.java @@ -0,0 +1,9 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.PrescriptionDetail; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PrescriptionDetailRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBStopVisitRepo.java b/src/main/java/com/iemr/flw/repo/iemr/TBStopVisitRepo.java deleted file mode 100644 index 51791436..00000000 --- a/src/main/java/com/iemr/flw/repo/iemr/TBStopVisitRepo.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.iemr.flw.repo.iemr; - -import com.iemr.flw.domain.iemr.TBStopVisit; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -import java.sql.Timestamp; - -@Repository -public interface TBStopVisitRepo extends JpaRepository { - - @Query("SELECT v FROM TBStopVisit v WHERE v.beneficiaryRegID = :beneficiaryRegID " + - "AND v.visitDate BETWEEN :start AND :end") - TBStopVisit findByBeneficiaryRegIDAndVisitDateBetween(@Param("beneficiaryRegID") Long beneficiaryRegID, - @Param("start") Timestamp start, - @Param("end") Timestamp end); - - @Query("SELECT COUNT(v) FROM TBStopVisit v WHERE v.beneficiaryRegID = :beneficiaryRegID") - Integer getVisitCountForBeneficiary(@Param("beneficiaryRegID") Long beneficiaryRegID); - - @Transactional - @Modifying - @Query("UPDATE TBStopVisit t SET t.vanSerialNo = t.id WHERE t.id = :id") - void updateVanSerialNo(@Param("id") Long id); -} diff --git a/src/main/java/com/iemr/flw/service/TBStopVisitService.java b/src/main/java/com/iemr/flw/service/TBStopVisitService.java index ec1d40e3..0e15fd9b 100644 --- a/src/main/java/com/iemr/flw/service/TBStopVisitService.java +++ b/src/main/java/com/iemr/flw/service/TBStopVisitService.java @@ -1,15 +1,9 @@ package com.iemr.flw.service; -import com.iemr.flw.domain.iemr.TBStopVisit; +import com.iemr.flw.domain.iemr.BenVisitDetail; public interface TBStopVisitService { - /** - * Returns today's visit row for this beneficiary, creating one (with visitNo = previous - * count + 1) if none exists yet for the current calendar day. No mobile change required — - * "new visit" is inferred purely from the save timestamp, same pattern MMU uses - * (count existing visits, increment, insert-only — never update an existing visit row). - */ - TBStopVisit getOrCreateVisitForToday(Long beneficiaryRegID, Integer providerServiceMapID, String createdBy, - Integer vanID, Integer parkingPlaceID); + BenVisitDetail getOrCreateVisitForToday(Long beneficiaryRegID, Integer providerServiceMapID, + String createdBy, Integer vanID, Integer parkingPlaceID); } diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java index 4c1c3c4d..f6b26d4a 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormResponseServiceImpl.java @@ -37,6 +37,7 @@ import com.iemr.flw.dto.iemr.SectionResponseDTO; import com.iemr.flw.domain.iemr.DynamicForm; import com.iemr.flw.masterEnum.FormResponseStatus; +import com.iemr.flw.masterEnum.FormResponseStatus; import com.iemr.flw.masterEnum.FormType; import com.iemr.flw.repo.iemr.DynamicFormRepo; import com.iemr.flw.repo.iemr.FormResponseRepo; @@ -48,6 +49,7 @@ import com.iemr.flw.repo.iemr.SectionResponseRepo; import com.iemr.flw.masterEnum.QuestionType; import com.iemr.flw.masterEnum.SectionPhase; +import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.DynamicFormResponseService; import com.iemr.flw.utils.JwtUtil; import com.iemr.flw.utils.exception.IEMRException; @@ -94,10 +96,13 @@ public class DynamicFormResponseServiceImpl implements DynamicFormResponseServic private final QuestionOptionRepo questionOptionRepo; private final FormResponseItemSaver itemSaver; private final JwtUtil jwtUtil; + private final CampConfigService campConfigService; @Override @Transactional public FormResponseDTO submitForm(FormResponseRequest request) { + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); FormVersion version = resolveLatestVersion(request.getFormUuid()); Timestamp now = new Timestamp(System.currentTimeMillis()); FormResponse formResponse; @@ -116,15 +121,17 @@ public FormResponseDTO submitForm(FormResponseRequest request) { formResponse.setLastFollowUpAt(now); formResponse = formResponseRepo.save(formResponse); } else { - formResponse = createFormResponse(request, version, FormResponseStatus.SUBMITTED.name(), now); + formResponse = createFormResponse(request, version, FormResponseStatus.SUBMITTED.name(), now, vanID, parkingPlaceID); } - return processSections(formResponse, request, version, null, false); + return processSections(formResponse, request, version, null, false, vanID, parkingPlaceID); } @Override @Transactional public FormResponseDTO completeForm(FormResponseRequest request, String jwtToken) throws IEMRException { String actor = jwtUtil.extractUserId(jwtToken).toString(); + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); FormVersion version = resolveLatestVersion(request.getFormUuid()); Long formId = version.getDynamicForm().getFormId(); @@ -133,7 +140,7 @@ public FormResponseDTO completeForm(FormResponseRequest request, String jwtToken List existing = formResponseRepo.findByBeneficiaryIdAndFormId(request.getBeneficiaryId(), formId); FormResponse formResponse = existing.isEmpty() - ? createFormResponse(request, version, FormResponseStatus.SUBMITTED.name(), now) + ? createFormResponse(request, version, FormResponseStatus.SUBMITTED.name(), now, vanID, parkingPlaceID) : existing.get(0); formResponse.setUpdatedBy(actor); @@ -142,7 +149,7 @@ public FormResponseDTO completeForm(FormResponseRequest request, String jwtToken : FormResponseStatus.COMPLETE.name()); formResponse.setCompletedAt(now); formResponse = formResponseRepo.save(formResponse); - return processSections(formResponse, request, version, actor, true); + return processSections(formResponse, request, version, actor, true, vanID, parkingPlaceID); } @Override @@ -179,7 +186,9 @@ private FormResponse createFormResponse( FormResponseRequest request, FormVersion version, String status, - Timestamp submittedAt) { + Timestamp submittedAt, + Integer vanID, + Integer parkingPlaceID) { FormResponse formResponse = FormResponse.builder() .beneficiaryId(request.getBeneficiaryId()) @@ -192,6 +201,8 @@ private FormResponse createFormResponse( // TODO: set audit fields when auth is re-enabled for /dynamicForm/response endpoints // .createdBy(jwtUtil.extractUsername(authorization)) // .updatedBy(jwtUtil.extractUsername(authorization)) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build(); return formResponseRepo.save(formResponse); } @@ -204,7 +215,9 @@ private FormResponseDTO processSections( FormResponseRequest request, FormVersion version, String actor, - boolean applyRefusalRule) { + boolean applyRefusalRule, + Integer vanID, + Integer parkingPlaceID) { List allSections = formSectionRepo.findByFormVersion_VersionIdOrderByDisplayOrderAsc(version.getVersionId()); @@ -251,7 +264,7 @@ private FormResponseDTO processSections( ? determineSectionStatus(section, sectionReq.getAnswers()) : SECTION_STATUS_DONE; Optional sectionResponseOpt = upsertSectionResponse( - formResponse.getResponseId(), section, actor, desiredStatus); + formResponse.getResponseId(), section, actor, desiredStatus, vanID, parkingPlaceID); if (sectionResponseOpt.isEmpty()) { continue; } @@ -270,7 +283,9 @@ private FormResponseDTO processSections( sectionReq.getAnswers(), questionMap, optionsByQuestion, - actor); + actor, + vanID, + parkingPlaceID); questionResponseRepo.saveAll(questionResponses); @@ -281,7 +296,7 @@ private FormResponseDTO processSections( } private Optional upsertSectionResponse( - Long responseId, FormSection section, String actor, String status) { + Long responseId, FormSection section, String actor, String status, Integer vanID, Integer parkingPlaceID) { Optional existing = sectionResponseRepo.findByResponseIdAndSectionId(responseId, section.getSectionId()); if (existing.isPresent()) { @@ -294,6 +309,7 @@ private Optional upsertSectionResponse( sr.setStatus(status); sr.setSavedAt(new Timestamp(System.currentTimeMillis())); sr.setUpdatedBy(actor); + if (sr.getVanID() == null) { sr.setVanID(vanID); sr.setParkingPlaceID(parkingPlaceID); } return Optional.of(sectionResponseRepo.save(sr)); } SectionResponse sr = SectionResponse.builder() @@ -303,6 +319,8 @@ private Optional upsertSectionResponse( .savedAt(new Timestamp(System.currentTimeMillis())) .createdBy(actor) .updatedBy(actor) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build(); return Optional.of(sectionResponseRepo.save(sr)); } @@ -344,7 +362,9 @@ private List processAnswers( List answers, Map questionMap, Map> optionsByQuestion, - String actor) { + String actor, + Integer vanID, + Integer parkingPlaceID) { List results = new ArrayList<>(); @@ -379,6 +399,8 @@ private List processAnswers( .optionId(opt != null ? opt.getOptionId() : null) .createdBy(actor) .updatedBy(actor) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build()); } } else if (type == QuestionType.MCQ) { @@ -392,6 +414,8 @@ private List processAnswers( .optionId(opt != null ? opt.getOptionId() : null) .createdBy(actor) .updatedBy(actor) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build()); } } @@ -406,6 +430,8 @@ private List processAnswers( .answerText(value) .createdBy(actor) .updatedBy(actor) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build()); } } diff --git a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java index 7b965223..4767f505 100644 --- a/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java +++ b/src/main/java/com/iemr/flw/service/impl/FormResponseItemSaver.java @@ -44,6 +44,7 @@ import com.iemr.flw.masterEnum.FormResponseStatus; import com.iemr.flw.masterEnum.QuestionType; import com.iemr.flw.masterEnum.SectionPhase; +import com.iemr.flw.service.CampConfigService; import com.iemr.flw.utils.JwtUtil; import com.iemr.flw.utils.exception.IEMRException; import lombok.RequiredArgsConstructor; @@ -84,6 +85,7 @@ public class FormResponseItemSaver { private final SectionQuestionRepo sectionQuestionRepo; private final QuestionOptionRepo questionOptionRepo; private final JwtUtil jwtUtil; + private final CampConfigService campConfigService; /** * Saves one form response in its own independent REQUIRES_NEW transaction. @@ -93,6 +95,8 @@ public class FormResponseItemSaver { @Transactional(propagation = Propagation.REQUIRES_NEW) public FormResponseDTO saveForBulk(FormResponseRequest req, String jwtToken) throws IEMRException { String actor = jwtUtil.extractUserId(jwtToken).toString(); + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); FormVersion version = resolveLatestVersion(req.getFormUuid()); Timestamp now = new Timestamp(System.currentTimeMillis()); Long formId = version.getDynamicForm().getFormId(); @@ -118,11 +122,11 @@ public FormResponseDTO saveForBulk(FormResponseRequest req, String jwtToken) thr log.info("saveForBulk: overwriting responseId={} for beneficiaryId={}", formResponse.getResponseId(), req.getBeneficiaryId()); } else { - formResponse = createFormResponse(req, version, now, actor); + formResponse = createFormResponse(req, version, now, actor, vanID, parkingPlaceID); } // Step 5: Insert fresh sections and answers - return processSections(formResponse, req, version, actor); + return processSections(formResponse, req, version, actor, vanID, parkingPlaceID); } /** Pre-flight: validates every sectionUuid and questionUuid in the request exist in the form. */ @@ -170,7 +174,8 @@ private FormVersion resolveLatestVersion(String formUuid) { "No active FormVersion found for form: " + formUuid)); } - private FormResponse createFormResponse(FormResponseRequest req, FormVersion version, Timestamp now, String actor) { + private FormResponse createFormResponse(FormResponseRequest req, FormVersion version, Timestamp now, String actor, + Integer vanID, Integer parkingPlaceID) { FormResponse formResponse = FormResponse.builder() .beneficiaryId(req.getBeneficiaryId()) .formId(version.getDynamicForm().getFormId()) @@ -181,11 +186,14 @@ private FormResponse createFormResponse(FormResponseRequest req, FormVersion ver .updatedBy(actor) .submittedAt(now) .lastFollowUpAt(now) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build(); return formResponseRepo.save(formResponse); } - private FormResponseDTO processSections(FormResponse formResponse, FormResponseRequest req, FormVersion version, String actor) { + private FormResponseDTO processSections(FormResponse formResponse, FormResponseRequest req, FormVersion version, String actor, + Integer vanID, Integer parkingPlaceID) { List allSections = formSectionRepo.findByFormVersion_VersionIdOrderByDisplayOrderAsc(version.getVersionId()); Map sectionByUuid = allSections.stream() @@ -240,7 +248,7 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR }); SectionResponse sectionResponse = upsertSectionResponse( - formResponse.getResponseId(), section.getSectionId(), actor); + formResponse.getResponseId(), section.getSectionId(), actor, vanID, parkingPlaceID); if (section.getSectionPhase() == SectionPhase.POST_SUBMIT) { formResponse.setLastFollowUpAt(sectionResponse.getSavedAt()); @@ -255,7 +263,9 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR sectionReq.getAnswers(), questionMap, optionsByQuestion, - actor); + actor, + vanID, + parkingPlaceID); questionResponseRepo.saveAll(questionResponses); sectionDTOs.add(buildSectionResponseDTO(sectionResponse, section, questionResponses)); @@ -264,7 +274,8 @@ private FormResponseDTO processSections(FormResponse formResponse, FormResponseR return buildFormResponseDTO(formResponse, sectionDTOs); } - private SectionResponse upsertSectionResponse(Long responseId, Long sectionId, String actor) { + private SectionResponse upsertSectionResponse(Long responseId, Long sectionId, String actor, + Integer vanID, Integer parkingPlaceID) { Optional existing = sectionResponseRepo.findByResponseIdAndSectionId(responseId, sectionId); if (existing.isPresent()) { @@ -272,6 +283,7 @@ private SectionResponse upsertSectionResponse(Long responseId, Long sectionId, S sr.setStatus(SECTION_STATUS_DONE); sr.setSavedAt(new Timestamp(System.currentTimeMillis())); sr.setUpdatedBy(actor); + if (sr.getVanID() == null) { sr.setVanID(vanID); sr.setParkingPlaceID(parkingPlaceID); } return sectionResponseRepo.save(sr); } SectionResponse sr = SectionResponse.builder() @@ -281,6 +293,8 @@ private SectionResponse upsertSectionResponse(Long responseId, Long sectionId, S .savedAt(new Timestamp(System.currentTimeMillis())) .createdBy(actor) .updatedBy(actor) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build(); return sectionResponseRepo.save(sr); } @@ -290,7 +304,9 @@ private List processAnswers( List answers, Map questionMap, Map> optionsByQuestion, - String actor) { + String actor, + Integer vanID, + Integer parkingPlaceID) { List results = new ArrayList<>(); for (QuestionAnswerRequest answer : answers) { @@ -322,6 +338,8 @@ private List processAnswers( .optionId(opt != null ? opt.getOptionId() : null) .createdBy(actor) .updatedBy(actor) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build()); } } else if (type == QuestionType.MCQ) { @@ -335,6 +353,8 @@ private List processAnswers( .optionId(opt != null ? opt.getOptionId() : null) .createdBy(actor) .updatedBy(actor) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build()); } } @@ -349,6 +369,8 @@ private List processAnswers( .answerText(value) .createdBy(actor) .updatedBy(actor) + .vanID(vanID) + .parkingPlaceID(parkingPlaceID) .build()); } } diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index cb347bbf..2098599b 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -5,15 +5,29 @@ import com.google.gson.JsonParser; import com.iemr.flw.domain.identity.RMNCHMBeneficiarydetail; import com.iemr.flw.domain.identity.RMNCHMBeneficiarymapping; +import com.iemr.flw.domain.iemr.BenAnthropometryDetail; +import com.iemr.flw.domain.iemr.BenChiefComplaint; import com.iemr.flw.domain.iemr.BenFlowStatus; +import com.iemr.flw.domain.iemr.BenPhysicalVitalDetail; +import com.iemr.flw.domain.iemr.BenReferDetails; +import com.iemr.flw.domain.iemr.BenVisitDetail; +import com.iemr.flw.domain.iemr.PhyGeneralExamination; +import com.iemr.flw.domain.iemr.PrescribedDrugDetail; +import com.iemr.flw.domain.iemr.PrescriptionDetail; import com.iemr.flw.domain.iemr.StopTBDiagnostics; import com.iemr.flw.domain.iemr.StopTBGeneralExamination; import com.iemr.flw.domain.iemr.StopTBGeneralOpd; import com.iemr.flw.domain.iemr.TBScreening; -import com.iemr.flw.domain.iemr.TBStopVisit; import com.iemr.flw.dto.iemr.StopTBRegistrationDto; import com.iemr.flw.repo.identity.BeneficiaryRepo; +import com.iemr.flw.repo.iemr.BenAnthropometryRepo; +import com.iemr.flw.repo.iemr.BenChiefComplaintRepo; import com.iemr.flw.repo.iemr.BenFlowStatusRepo; +import com.iemr.flw.repo.iemr.BenPhysicalVitalRepo; +import com.iemr.flw.repo.iemr.BenReferDetailsRepo; +import com.iemr.flw.repo.iemr.PhyGeneralExaminationRepo; +import com.iemr.flw.repo.iemr.PrescribedDrugDetailRepo; +import com.iemr.flw.repo.iemr.PrescriptionDetailRepo; import com.iemr.flw.repo.iemr.StopTBDiagnosticsRepo; import com.iemr.flw.repo.iemr.StopTBGeneralExaminationRepo; import com.iemr.flw.repo.iemr.StopTBGeneralOpdRepo; @@ -64,6 +78,27 @@ public class StopTBServiceImpl implements StopTBService { @Autowired private TBStopVisitService tbStopVisitService; + @Autowired + private BenAnthropometryRepo benAnthropometryRepo; + + @Autowired + private BenPhysicalVitalRepo benPhysicalVitalRepo; + + @Autowired + private PhyGeneralExaminationRepo phyGeneralExaminationRepo; + + @Autowired + private BenChiefComplaintRepo benChiefComplaintRepo; + + @Autowired + private PrescriptionDetailRepo prescriptionDetailRepo; + + @Autowired + private PrescribedDrugDetailRepo prescribedDrugDetailRepo; + + @Autowired + private BenReferDetailsRepo benReferDetailsRepo; + @Value("${tm-url}") private String tmUrl; @@ -299,15 +334,16 @@ public List> saveGeneralExamination(List Integer providerServiceMapID = getInt(data, "providerServiceMapID"); String createdBy = getString(data, "createdBy"); - TBStopVisit visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, createdBy, vanID, parkingPlaceID); - StopTBGeneralExamination exam = generalExaminationRepo.findByBeneficiaryRegIDAndVisitCode(beneficiaryRegID, visit.getId()); + StopTBGeneralExamination exam = generalExaminationRepo.findByBeneficiaryRegIDAndVisitCode(beneficiaryRegID, visit.getVisitCode()); if (exam == null) exam = new StopTBGeneralExamination(); boolean isNew = exam.getId() == null; exam.setBeneficiaryRegID(beneficiaryRegID); - exam.setVisitCode(visit.getId()); + exam.setVisitCode(visit.getVisitCode()); + exam.setBenVisitID(visit.getBenVisitId()); exam.setProviderServiceMapID(providerServiceMapID); exam.setPulseRate(getInt(data, "pulseRate")); exam.setSystolicBP(getInt(data, "systolicBP")); @@ -337,7 +373,10 @@ public List> saveGeneralExamination(List exam.setProcessed("N"); generalExaminationRepo.save(exam); - if (isNew) generalExaminationRepo.updateVanSerialNo(exam.getId()); + if (isNew) { + generalExaminationRepo.updateVanSerialNo(exam.getId()); + dualWriteExamToStandardTables(exam, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + } Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); @@ -422,15 +461,15 @@ public List> saveNurseTBScreening(List> Integer providerServiceMapID = getInt(data, "providerServiceMapID"); String createdBy = getString(data, "createdBy"); - TBStopVisit visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, createdBy, vanID, parkingPlaceID); - TBScreening screening = tbScreeningRepo.findByBenRegIDAndVisitCode(beneficiaryRegID, visit.getId()); + TBScreening screening = tbScreeningRepo.findByBenRegIDAndVisitCode(beneficiaryRegID, visit.getVisitCode()); if (screening == null) screening = new TBScreening(); boolean isNew = screening.getId() == null; screening.setBenRegID(beneficiaryRegID); - screening.setVisitCode(visit.getId()); + screening.setVisitCode(visit.getVisitCode()); screening.setProviderServiceMapID(providerServiceMapID); screening.setCoughMoreThan2Weeks(getBool(data, "coughMoreThan2Weeks")); screening.setBloodInSputum(getBool(data, "bloodInSputum")); @@ -550,15 +589,16 @@ public List> saveGeneralOpd(List> dataLi Integer providerServiceMapID = getInt(data, "providerServiceMapID"); String createdBy = getString(data, "createdBy"); - TBStopVisit visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, providerServiceMapID, createdBy, vanID, parkingPlaceID); - StopTBGeneralOpd opd = generalOpdRepo.findByBenRegIDAndVisitCode(beneficiaryRegID, visit.getId()); + StopTBGeneralOpd opd = generalOpdRepo.findByBenRegIDAndVisitCode(beneficiaryRegID, visit.getVisitCode()); if (opd == null) opd = new StopTBGeneralOpd(); boolean isNew = opd.getId() == null; opd.setBenRegID(beneficiaryRegID); - opd.setVisitCode(visit.getId()); + opd.setVisitCode(visit.getVisitCode()); + opd.setBenVisitID(visit.getBenVisitId()); opd.setProviderServiceMapID(providerServiceMapID); opd.setChiefComplaint(toJsonString(data.get("chiefComplaint"))); opd.setMedication(getString(data, "medication")); @@ -573,7 +613,10 @@ public List> saveGeneralOpd(List> dataLi opd.setProcessed("N"); generalOpdRepo.save(opd); - if (isNew) generalOpdRepo.updateVanSerialNo(opd.getId()); + if (isNew) { + generalOpdRepo.updateVanSerialNo(opd.getId()); + dualWriteOpdToStandardTables(opd, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + } Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); @@ -754,6 +797,189 @@ public Map submitNurseData(Map data) throws Exce return result; } + // ── Standard table dual-writes ──────────────────────────────────────────── + + private Map getRegistrationExtras(Long beneficiaryRegID) { + try { + RMNCHMBeneficiarymapping mapping = beneficiaryRepo.getById(BigInteger.valueOf(beneficiaryRegID)); + RMNCHMBeneficiarydetail detail = (mapping != null && mapping.getBenDetailsId() != null) + ? beneficiaryRepo.getDetailsById(mapping.getBenDetailsId()) + : null; + if (detail != null && detail.getOtherFields() != null && !detail.getOtherFields().isEmpty()) { + return new ObjectMapper().readValue(detail.getOtherFields(), Map.class); + } + } catch (Exception e) { + logger.warn("Cannot read otherFields for benRegID: " + beneficiaryRegID); + } + return Collections.emptyMap(); + } + + private void dualWriteExamToStandardTables(StopTBGeneralExamination exam, Long beneficiaryRegID, + BenVisitDetail visit, String createdBy, Integer vanID, Integer parkingPlaceID) { + try { + Map extras = getRegistrationExtras(beneficiaryRegID); + writeAnthropometry(extras, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + writeVitals(exam, extras, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + writePhyGeneralExam(exam, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + if (Boolean.TRUE.equals(exam.getReferralToHWCNeeded())) { + writeReferral(beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + } + } catch (Exception e) { + logger.error("Dual-write exam to standard tables failed for benRegID: " + beneficiaryRegID, e); + } + } + + private void writeAnthropometry(Map extras, Long beneficiaryRegID, + BenVisitDetail visit, String createdBy, Integer vanID, Integer parkingPlaceID) { + try { + BenAnthropometryDetail a = new BenAnthropometryDetail(); + a.setBeneficiaryRegID(beneficiaryRegID); + a.setBenVisitID(visit.getBenVisitId()); + a.setVisitCode(visit.getVisitCode()); + a.setProviderServiceMapID(visit.getProviderServiceMapID()); + a.setCreatedBy(createdBy); + a.setVanID(vanID); + a.setParkingPlaceID(parkingPlaceID); + Object h = extras.get("height"); + Object w = extras.get("weight"); + Object b = extras.get("bmi"); + if (h instanceof Number) a.setHeightCm(((Number) h).doubleValue()); + if (w instanceof Number) a.setWeightKg(((Number) w).doubleValue()); + if (b instanceof Number) a.setBmi(((Number) b).doubleValue()); + benAnthropometryRepo.save(a); + } catch (Exception e) { + logger.error("writeAnthropometry failed for benRegID: " + beneficiaryRegID, e); + } + } + + private void writeVitals(StopTBGeneralExamination exam, Map extras, + Long beneficiaryRegID, BenVisitDetail visit, String createdBy, Integer vanID, Integer parkingPlaceID) { + try { + BenPhysicalVitalDetail v = new BenPhysicalVitalDetail(); + v.setBeneficiaryRegID(beneficiaryRegID); + v.setBenVisitID(visit.getBenVisitId()); + v.setVisitCode(visit.getVisitCode()); + v.setProviderServiceMapID(visit.getProviderServiceMapID()); + v.setCreatedBy(createdBy); + v.setVanID(vanID); + v.setParkingPlaceID(parkingPlaceID); + Object t = extras.get("temperatureValue"); + if (t instanceof Number) v.setTemperature(((Number) t).doubleValue()); + if (exam.getPulseRate() != null) v.setPulseRate(exam.getPulseRate().shortValue()); + if (exam.getSystolicBP() != null) v.setSystolicBP(exam.getSystolicBP().shortValue()); + if (exam.getDiastolicBP() != null) v.setDiastolicBP(exam.getDiastolicBP().shortValue()); + if (exam.getRandomBloodSugar() != null) v.setBloodGlucoseRandom(exam.getRandomBloodSugar().shortValue()); + benPhysicalVitalRepo.save(v); + } catch (Exception e) { + logger.error("writeVitals failed for benRegID: " + beneficiaryRegID, e); + } + } + + private void writePhyGeneralExam(StopTBGeneralExamination exam, Long beneficiaryRegID, + BenVisitDetail visit, String createdBy, Integer vanID, Integer parkingPlaceID) { + try { + PhyGeneralExamination g = new PhyGeneralExamination(); + g.setBeneficiaryRegID(beneficiaryRegID); + g.setBenVisitID(visit.getBenVisitId()); + g.setVisitCode(visit.getVisitCode()); + g.setProviderServiceMapID(visit.getProviderServiceMapID()); + g.setCreatedBy(createdBy); + g.setVanID(vanID); + g.setParkingPlaceID(parkingPlaceID); + g.setPallor(exam.getPallor()); + g.setJaundice(exam.getIcterus()); + g.setCyanosis(exam.getCyanosis()); + g.setClubbing(exam.getClubbing()); + g.setLymphadenopathy(exam.getLymphadenopathy()); + g.setEdema(exam.getOedema()); + phyGeneralExaminationRepo.save(g); + } catch (Exception e) { + logger.error("writePhyGeneralExam failed for benRegID: " + beneficiaryRegID, e); + } + } + + private void writeReferral(Long beneficiaryRegID, BenVisitDetail visit, + String createdBy, Integer vanID, Integer parkingPlaceID) { + try { + BenReferDetails r = new BenReferDetails(); + r.setBeneficiaryRegID(beneficiaryRegID); + r.setBenVisitID(visit.getBenVisitId()); + r.setVisitCode(visit.getVisitCode()); + r.setProviderServiceMapID(visit.getProviderServiceMapID()); + r.setCreatedBy(createdBy); + r.setVanID(vanID); + r.setParkingPlaceID(parkingPlaceID); + r.setReferredToInstituteName("HWC"); + r.setReferralReason("Stop TB Referral"); + benReferDetailsRepo.save(r); + } catch (Exception e) { + logger.error("writeReferral failed for benRegID: " + beneficiaryRegID, e); + } + } + + private void dualWriteOpdToStandardTables(StopTBGeneralOpd opd, Long beneficiaryRegID, + BenVisitDetail visit, String createdBy, Integer vanID, Integer parkingPlaceID) { + try { + writeChiefComplaint(opd.getChiefComplaint(), beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + writePrescription(opd, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); + } catch (Exception e) { + logger.error("Dual-write OPD to standard tables failed for benRegID: " + beneficiaryRegID, e); + } + } + + private void writeChiefComplaint(String complaint, Long beneficiaryRegID, BenVisitDetail visit, + String createdBy, Integer vanID, Integer parkingPlaceID) { + if (complaint == null || complaint.isBlank()) return; + try { + BenChiefComplaint c = new BenChiefComplaint(); + c.setBeneficiaryRegID(beneficiaryRegID); + c.setBenVisitID(visit.getBenVisitId()); + c.setVisitCode(visit.getVisitCode()); + c.setProviderServiceMapID(visit.getProviderServiceMapID()); + c.setCreatedBy(createdBy); + c.setVanID(vanID); + c.setParkingPlaceID(parkingPlaceID); + c.setChiefComplaint(complaint); + benChiefComplaintRepo.save(c); + } catch (Exception e) { + logger.error("writeChiefComplaint failed for benRegID: " + beneficiaryRegID, e); + } + } + + private void writePrescription(StopTBGeneralOpd opd, Long beneficiaryRegID, BenVisitDetail visit, + String createdBy, Integer vanID, Integer parkingPlaceID) { + if (opd.getMedication() == null || opd.getMedication().isBlank()) return; + try { + PrescriptionDetail p = new PrescriptionDetail(); + p.setBeneficiaryRegID(beneficiaryRegID); + p.setBenVisitID(visit.getBenVisitId()); + p.setVisitCode(visit.getVisitCode()); + p.setProviderServiceMapID(visit.getProviderServiceMapID()); + p.setCreatedBy(createdBy); + p.setVanID(vanID); + p.setParkingPlaceID(parkingPlaceID); + p.setInstruction(opd.getNotes()); + p = prescriptionDetailRepo.save(p); + + PrescribedDrugDetail d = new PrescribedDrugDetail(); + d.setBeneficiaryRegID(beneficiaryRegID); + d.setBenVisitID(visit.getBenVisitId()); + d.setVisitCode(visit.getVisitCode()); + d.setProviderServiceMapID(visit.getProviderServiceMapID()); + d.setCreatedBy(createdBy); + d.setVanID(vanID); + d.setParkingPlaceID(parkingPlaceID); + d.setPrescriptionID(p.getPrescriptionID()); + d.setDrugName(opd.getMedication()); + d.setDose(opd.getDosage()); + d.setFrequency(opd.getFrequency()); + d.setDuration(opd.getDuration()); + prescribedDrugDetailRepo.save(d); + } catch (Exception e) { + logger.error("writePrescription failed for benRegID: " + beneficiaryRegID, e); + } + } + // ── Helpers ─────────────────────────────────────────────────────────────── private Timestamp parseDob(JsonObject obj) { diff --git a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java index 01ab464a..a38073d6 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java @@ -5,7 +5,7 @@ import com.iemr.flw.domain.iemr.DynamicForm; import com.iemr.flw.domain.iemr.TBConfirmedCaseDTO; import com.iemr.flw.domain.iemr.TBConfirmedCase; -import com.iemr.flw.domain.iemr.TBStopVisit; +import com.iemr.flw.domain.iemr.BenVisitDetail; import com.iemr.flw.repo.iemr.DynamicFormRepo; import com.iemr.flw.repo.iemr.FormResponseRepo; import com.iemr.flw.repo.iemr.TBConfirmedTreatmentRepository; @@ -68,14 +68,14 @@ public String save(List request, String authorisation) throw for(TBConfirmedCaseDTO dto:request){ String modifiedBy = jwtUtil.extractUsername(authorisation); - TBStopVisit visit = tbStopVisitService.getOrCreateVisitForToday(dto.getBenId(), null, + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(dto.getBenId(), null, modifiedBy, vanID, parkingPlaceID); - List existing = repository.findByBenIdAndVisitCode(dto.getBenId(), visit.getId()); + List existing = repository.findByBenIdAndVisitCode(dto.getBenId(), visit.getVisitCode()); boolean isNew = existing.isEmpty(); TBConfirmedCase entity = isNew ? new TBConfirmedCase() : existing.get(0); entity.setBenId(dto.getBenId()); - entity.setVisitCode(visit.getId()); + entity.setVisitCode(visit.getVisitCode()); entity.setUserId(jwtUtil.extractUserId(authorisation)); entity.setModifiedBy(modifiedBy); entity.setRegimenType(dto.getRegimenType()); diff --git a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java index 5907b678..a54ebe4f 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java @@ -1,7 +1,7 @@ package com.iemr.flw.service.impl; -import com.iemr.flw.domain.iemr.TBStopVisit; -import com.iemr.flw.repo.iemr.TBStopVisitRepo; +import com.iemr.flw.domain.iemr.BenVisitDetail; +import com.iemr.flw.repo.iemr.BenVisitDetailsRepo; import com.iemr.flw.service.TBStopVisitService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -14,34 +14,45 @@ public class TBStopVisitServiceImpl implements TBStopVisitService { @Autowired - private TBStopVisitRepo tbStopVisitRepo; + private BenVisitDetailsRepo benVisitDetailsRepo; @Override @Transactional - public TBStopVisit getOrCreateVisitForToday(Long beneficiaryRegID, Integer providerServiceMapID, String createdBy, - Integer vanID, Integer parkingPlaceID) { + public BenVisitDetail getOrCreateVisitForToday(Long beneficiaryRegID, Integer providerServiceMapID, + String createdBy, Integer vanID, Integer parkingPlaceID) { LocalDate today = LocalDate.now(); - Timestamp todayStart = Timestamp.valueOf(today.atStartOfDay()); - Timestamp todayEnd = Timestamp.valueOf(today.plusDays(1).atStartOfDay()); - - TBStopVisit visit = tbStopVisitRepo.findByBeneficiaryRegIDAndVisitDateBetween(beneficiaryRegID, todayStart, todayEnd); - if (visit != null) { - return visit; - } - - Integer count = tbStopVisitRepo.getVisitCountForBeneficiary(beneficiaryRegID); - visit = new TBStopVisit(); - visit.setBeneficiaryRegID(beneficiaryRegID); - visit.setVisitNo(count != null ? count + 1 : 1); + Timestamp dayStart = Timestamp.valueOf(today.atStartOfDay()); + Timestamp dayEnd = Timestamp.valueOf(today.plusDays(1).atStartOfDay()); + + BenVisitDetail existing = benVisitDetailsRepo.findStopTBVisitForToday(beneficiaryRegID, dayStart, dayEnd); + if (existing != null) return existing; + + Integer visitCount = benVisitDetailsRepo.countStopTBVisits(beneficiaryRegID); + short visitNo = (short) ((visitCount != null ? visitCount : 0) + 1); + + BenVisitDetail visit = new BenVisitDetail(); + visit.setBeneficiaryRegId(beneficiaryRegID); + visit.setVisitNo(visitNo); + visit.setVisitCategory("Stop TB"); + visit.setVisitReason("Screening"); + visit.setVisitDateTime(new Timestamp(System.currentTimeMillis())); visit.setProviderServiceMapID(providerServiceMapID); visit.setCreatedBy(createdBy); - if (vanID != null) { - visit.setVanID(vanID); - visit.setParkingPlaceID(parkingPlaceID); - } + visit.setVanId(vanID); + visit.setParkingPlaceId(parkingPlaceID); + visit.setDeleted(false); visit.setProcessed("N"); - visit = tbStopVisitRepo.save(visit); - tbStopVisitRepo.updateVanSerialNo(visit.getId()); - return visit; + visit = benVisitDetailsRepo.save(visit); + + // VisitCode: sessionID(1) + vanID(5-digit) + benVisitId(8-digit) — MMU formula + long visitCode = generateVisitCode(visit.getBenVisitId(), vanID != null ? vanID : 1); + visit.setVisitCode(visitCode); + return benVisitDetailsRepo.save(visit); + } + + private long generateVisitCode(long visitId, int vanID) { + String vanStr = String.format("%05d", vanID); + String visitStr = String.format("%08d", visitId); + return Long.parseLong("1" + vanStr + visitStr); } } diff --git a/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java index 28f05180..d675a01d 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java @@ -2,7 +2,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.iemr.flw.domain.iemr.TBStopVisit; +import com.iemr.flw.domain.iemr.BenVisitDetail; import com.iemr.flw.domain.iemr.TBSuspected; import com.iemr.flw.dto.identity.GetBenRequestHandler; import com.iemr.flw.dto.iemr.TBSuspectedDTO; @@ -42,11 +42,11 @@ public String save(TBSuspectedRequestDTO requestDTO) throws Exception { Integer vanID = campConfigService.getVanID(); Integer parkingPlaceID = campConfigService.getParkingPlaceID(); for (TBSuspectedDTO tbSuspectedDTO : requestDTO.getTbSuspectedList()) { - TBStopVisit visit = tbStopVisitService.getOrCreateVisitForToday(tbSuspectedDTO.getBenId(), null, + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(tbSuspectedDTO.getBenId(), null, requestDTO.getUserId() != null ? requestDTO.getUserId().toString() : null, vanID, parkingPlaceID); TBSuspected tbSuspected = - tbSuspectedRepo.getByUserIdAndBenIdAndVisitCode(tbSuspectedDTO.getBenId(), requestDTO.getUserId(), visit.getId()); + tbSuspectedRepo.getByUserIdAndBenIdAndVisitCode(tbSuspectedDTO.getBenId(), requestDTO.getUserId(), visit.getVisitCode()); if (tbSuspected == null) { tbSuspected = new TBSuspected(); @@ -59,7 +59,7 @@ public String save(TBSuspectedRequestDTO requestDTO) throws Exception { } tbSuspected.setUserId(requestDTO.getUserId()); - tbSuspected.setVisitCode(visit.getId()); + tbSuspected.setVisitCode(visit.getVisitCode()); if (tbSuspected.getVanID() == null && vanID != null) { tbSuspected.setVanID(vanID); tbSuspected.setParkingPlaceID(parkingPlaceID); } tbSuspected.setProcessed("N"); tbSuspectedRepo.save(tbSuspected); From c53b9b546e5e5525e0184e94560cff621686ef8f Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 30 Jun 2026 14:23:03 +0530 Subject: [PATCH 20/47] fix(stoptb): getBeneficiaryData reads anthropometry/vitals from standard tables - getBeneficiaryData now reads height/weight/bmi from t_phy_anthropometry and temperature/pulse/BP/RBS from t_phy_vitals (latest row per beneficiary) - Fallback to otherFields if exam not yet saved for the beneficiary - Stop TB specific fields (personFrom, caseFindingType etc.) still from otherFields - Add findByBeneficiaryRegIDOrderByCreatedDateDesc and findByBenVisitID to BenAnthropometryRepo and BenPhysicalVitalRepo - Add StopTB_Future_Roadmap.md for future HWC integration and edit support Co-Authored-By: Claude Sonnet 4.6 --- StopTB_Future_Roadmap.md | 246 ++++++++++++++++++ .../flw/repo/iemr/BenAnthropometryRepo.java | 6 + .../flw/repo/iemr/BenPhysicalVitalRepo.java | 6 + .../service/impl/BeneficiaryServiceImpl.java | 58 ++++- 4 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 StopTB_Future_Roadmap.md diff --git a/StopTB_Future_Roadmap.md b/StopTB_Future_Roadmap.md new file mode 100644 index 00000000..d6fea62b --- /dev/null +++ b/StopTB_Future_Roadmap.md @@ -0,0 +1,246 @@ +# Stop TB — Future Roadmap + +**Date:** 2026-06-30 +**Author:** vishwab1 +**Prerequisite:** Read `StopTB_Standard_Table_Integration.md` first for current implementation context. + +--- + +## Why this document exists + +The current dual-write implementation (FLW-API) writes Stop TB clinical data to both custom +`tb_stoptb_*` tables and standard AMRIT tables (`t_phy_vitals`, `t_Phy_GeneralExam` etc.). + +This document captures what still needs to be done in future phases — HWC integration, +edit support, worklist, and BenFlowStatus advancement. + +**None of these future changes require a mobile app change.** + +--- + +## 1. HWC-API — Read common fields from standard tables + +**Trigger:** When HWC doctor needs to see Stop TB beneficiary's vitals/examination history. + +**Problem:** HWC-API currently queries standard tables with a `providerServiceMapID` filter: +```sql +SELECT * FROM t_phy_vitals +WHERE beneficiaryRegID = ? AND providerServiceMapID = ? -- HWC's own PSMID +``` +Stop TB data was written with Stop TB's PSMID → HWC query misses it. + +**Fix — HWC-API only, remove PSMID filter:** +```sql +SELECT * FROM t_phy_vitals +WHERE beneficiaryRegID = ? -- no PSMID filter, shows all service lines +ORDER BY CreatedDate DESC +``` + +**Affected HWC-API endpoints:** +- `getBenPhysicalVitalDetail` +- `getBenAnthropometryDetail` +- `getBenPhyGeneralExamination` +- `getBenChiefComplaint` +- `getBenPrescriptionDetail` + +**Effort:** Small — query change only in HWC-API. No schema change. No FLW-API change. + +--- + +## 2. HWC-API — Read TB specific data from custom tables directly + +**Trigger:** When HWC doctor needs full TB clinical picture — screening questions, diagnostics, +Nikshay ID, treatment tracking. + +Standard tables only hold common clinical fields. TB specific data lives in custom tables. +HWC-API needs to query these directly by `benRegID`. + +**New queries needed in HWC-API:** + +```java +// TB Screening +tbScreeningRepo.findByBenRegIDOrderByCreatedDateDesc(benRegID); + +// Diagnostics (Nikshay ID, X-ray, Truenat, Sputum, Liquid Culture) +tbDiagnosticsRepo.findByBenRegIDOrderByCreatedDateDesc(benRegID); + +// Suspected cases +tbSuspectedRepo.findByBenIdOrderByCreatedDateDesc(benId); + +// Confirmed cases + treatment +tbConfirmedRepo.findByBenIdOrderByCreatedDateDesc(benId); +``` + +**Tables queried:** +| Table | Key data | +|---|---| +| `tb_screening` | Cough, fever, night sweats, weight loss, family history | +| `tb_stoptb_diagnostics` | Nikshay ID, X-ray result, Truenat result, Liquid Culture result | +| `tb_suspected` | Sputum result, chest X-ray, referral, TB/DRTB confirmed flag | +| `tb_confirmed_cases` | Regimen, treatment dates, adherence, outcome | + +**Effort:** Small — new repo methods + new HWC-API service calls. No schema change. + +--- + +## 3. FLW-API — Edit support for standard table dual-write + +**Trigger:** When mobile app adds edit/update capability for nurse data. + +**Current behaviour:** Dual-write only happens on first save (`isNew = true`). On re-save, +only `tb_stoptb_*` custom tables are updated. Standard tables keep the original values. + +**What breaks:** If mobile edits vitals → `tb_stoptb_general_examination` gets new values +but `t_phy_vitals` keeps old values → HWC sees stale data. + +### Changes in StopTBServiceImpl + +```java +// Change from: +if (isNew) { + generalExaminationRepo.updateVanSerialNo(exam.getId()); + dualWriteExamToStandardTables(exam, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); +} + +// Change to: +if (isNew) generalExaminationRepo.updateVanSerialNo(exam.getId()); +dualWriteExamToStandardTables(exam, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); +``` + +Same change for `saveGeneralOpd()`: +```java +// Remove isNew guard around dualWriteOpdToStandardTables +if (isNew) generalOpdRepo.updateVanSerialNo(opd.getId()); +dualWriteOpdToStandardTables(opd, beneficiaryRegID, visit, createdBy, vanID, parkingPlaceID); +``` + +### Changes in dual-write helpers — upsert instead of insert + +Each helper must find existing row first, update if found, insert if not: + +```java +private void writeVitals(StopTBGeneralExamination exam, ...) { + try { + // Find existing row for this visit + BenPhysicalVitalDetail existing = + benPhysicalVitalRepo.findByBenVisitID(visit.getBenVisitId()); + BenPhysicalVitalDetail v = existing != null ? existing : new BenPhysicalVitalDetail(); + + v.setBeneficiaryRegID(beneficiaryRegID); + v.setBenVisitID(visit.getBenVisitId()); + v.setVisitCode(visit.getVisitCode()); + // ... set all fields + benPhysicalVitalRepo.save(v); + } catch (Exception e) { + logger.error("writeVitals failed", e); + } +} +``` + +Same upsert pattern for: `writeAnthropometry()`, `writePhyGeneralExam()`, +`writeChiefComplaint()`, `writePrescription()`. + +### New repo methods needed (FLW-API) + +```java +// BenPhysicalVitalRepo +BenPhysicalVitalDetail findByBenVisitID(Long benVisitID); + +// BenAnthropometryRepo +BenAnthropometryDetail findByBenVisitID(Long benVisitID); + +// PhyGeneralExaminationRepo +PhyGeneralExamination findByBenVisitID(Long benVisitID); + +// BenChiefComplaintRepo +BenChiefComplaint findByBenVisitID(Long benVisitID); +``` + +> **Note:** `t_prescription` and `t_prescribeddrug` have a parent-child FK relationship. +> For prescription edit: find existing `PrescriptionDetail` by `benVisitID`, delete old +> `PrescribedDrugDetail` rows, re-insert fresh drug rows. + +**Effort:** Small — remove isNew guard, add findByBenVisitID to 4 repos, convert helpers to upsert. + +--- + +## 4. FLW-API — nurseFlag advancement + +**Trigger:** When a web dashboard / supervisor screen is built for Stop TB. + +**Current state:** `nurseFlag` in `i_ben_flow_outreach` is set to `1` at registration and +never advances because mobile never calls `flw-api/stoptb/nurse/submit`. + +**What to implement:** + +After each of the 3 mandatory nurse saves (generalExamination, tbScreening, generalOpd), +check if all 3 are complete for the same `visitCode`. If yes, auto-advance `nurseFlag = 9`. + +```java +private void tryAdvanceNurseFlag(Long benRegID, Long visitCode, Integer psmID) { + boolean examDone = generalExaminationRepo + .findByBeneficiaryRegIDAndVisitCode(benRegID, visitCode) != null; + boolean screeningDone = tbScreeningRepo + .findByBenRegIDAndVisitCode(benRegID, visitCode) != null; + boolean opdDone = generalOpdRepo + .findByBenRegIDAndVisitCode(benRegID, visitCode) != null; + + if (examDone && screeningDone && opdDone) { + benFlowStatusRepo.updateStopTBAfterNurseSubmit(benRegID, psmID); + // updateStopTBAfterNurseSubmit() already exists in BenFlowStatusRepo + } +} +``` + +Call `tryAdvanceNurseFlag()` at the end of each of the 3 save methods in `StopTBServiceImpl`. + +**Effort:** Small — new private method in StopTBServiceImpl, reuses existing repo query. + +--- + +## 5. HWC-API + HWC-UI — Stop TB referral worklist + +**Trigger:** When HWC wants to track Stop TB beneficiaries referred to it. + +**Current state:** When Stop TB nurse marks `referralToHWCNeeded = true`, FLW-API writes a +row to `t_benreferdetails` with `referredToInstituteName = 'HWC'` and +`referralReason = 'Stop TB Referral'`. HWC-API has no endpoint that surfaces these rows. + +**HWC-API change needed:** + +New query in `BenReferDetailsRepo`: +```java +@Query("SELECT r FROM BenReferDetails r WHERE r.beneficiaryRegID = :benRegID " + + "AND r.referralReason = 'Stop TB Referral' AND r.deleted = false " + + "ORDER BY r.createdDate DESC") +List findStopTBReferrals(@Param("benRegID") Long benRegID); +``` + +Or for worklist (all referred beneficiaries for a provider): +```java +@Query("SELECT r FROM BenReferDetails r WHERE r.referredToInstituteName = 'HWC' " + + "AND r.referralReason = 'Stop TB Referral' AND r.deleted = false") +List findAllStopTBReferrals(); +``` + +**HWC-UI change needed:** +- New worklist tab or filter: "Stop TB Referrals" +- Shows beneficiary name, referral date, FLW name + +**Effort:** Medium — HWC-API new endpoint + HWC-UI new screen. + +--- + +## Summary table + +| # | Change | Service | Effort | Blocks on | +|---|---|---|---|---| +| 1 | Read standard tables without PSMID filter | HWC-API | Small | HWC team decision | +| 2 | Read TB specific custom tables | HWC-API | Small | HWC team decision | +| 3 | Edit support — upsert in dual-write helpers | FLW-API | Small | Mobile adding edit | +| 4 | nurseFlag auto-advancement | FLW-API | Small | Web dashboard being built | +| 5 | Stop TB referral worklist | HWC-API + HWC-UI | Medium | HWC team + product decision | + +**No mobile change required for any of these.** +**No schema change required for items 1, 2, 3, 4.** +**Item 5 may need a column in `t_benreferdetails` for status tracking (pending/accepted/rejected) — TBD by HWC team.** diff --git a/src/main/java/com/iemr/flw/repo/iemr/BenAnthropometryRepo.java b/src/main/java/com/iemr/flw/repo/iemr/BenAnthropometryRepo.java index b7c9efd3..1856eb06 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/BenAnthropometryRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/BenAnthropometryRepo.java @@ -4,6 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface BenAnthropometryRepo extends JpaRepository { + + List findByBeneficiaryRegIDOrderByCreatedDateDesc(Long beneficiaryRegID); + + BenAnthropometryDetail findByBenVisitID(Long benVisitID); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/BenPhysicalVitalRepo.java b/src/main/java/com/iemr/flw/repo/iemr/BenPhysicalVitalRepo.java index cf1ed35b..7e474935 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/BenPhysicalVitalRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/BenPhysicalVitalRepo.java @@ -4,6 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface BenPhysicalVitalRepo extends JpaRepository { + + List findByBeneficiaryRegIDOrderByCreatedDateDesc(Long beneficiaryRegID); + + BenPhysicalVitalDetail findByBenVisitID(Long benVisitID); } diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index bc146ca4..016576d5 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -53,8 +53,12 @@ import com.iemr.flw.domain.identity.RMNCHMBeneficiarymapping; import com.iemr.flw.dto.identity.GetBenRequestHandler; import com.iemr.flw.mapper.InputMapper; +import com.iemr.flw.domain.iemr.BenAnthropometryDetail; +import com.iemr.flw.domain.iemr.BenPhysicalVitalDetail; import com.iemr.flw.repo.identity.BeneficiaryRepo; import com.iemr.flw.repo.identity.HouseHoldRepo; +import com.iemr.flw.repo.iemr.BenAnthropometryRepo; +import com.iemr.flw.repo.iemr.BenPhysicalVitalRepo; import com.iemr.flw.service.BeneficiaryService; import com.iemr.flw.utils.config.ConfigProperties; import com.iemr.flw.utils.http.HttpUtils; @@ -100,6 +104,12 @@ public class BeneficiaryServiceImpl implements BeneficiaryService { @Autowired private UserServiceRoleRepo userRepo; + @Autowired + private BenAnthropometryRepo benAnthropometryRepo; + + @Autowired + private BenPhysicalVitalRepo benPhysicalVitalRepo; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd-MM-yyyy"); @@ -499,17 +509,51 @@ private String getMappingsForAddressIDs(List addressLi if (benDetailsOBJ != null && benDetailsOBJ.getOccupation() != null) benDetailsRMNCH_OBJ.setOccupation(benDetailsOBJ.getOccupation()); - // anthropometry and Stop TB fields from ExtraFields + // anthropometry from t_phy_anthropometry, vitals from t_phy_vitals + // fallback to otherFields if exam not yet saved for this beneficiary + Long benRegIdLong = m.getBenRegId() != null ? m.getBenRegId().longValue() : null; + if (benRegIdLong != null) { + List anthroList = + benAnthropometryRepo.findByBeneficiaryRegIDOrderByCreatedDateDesc(benRegIdLong); + List vitalList = + benPhysicalVitalRepo.findByBeneficiaryRegIDOrderByCreatedDateDesc(benRegIdLong); + + Map anthropometry = new HashMap<>(); + if (!anthroList.isEmpty()) { + BenAnthropometryDetail a = anthroList.get(0); + if (a.getHeightCm() != null) anthropometry.put("height", a.getHeightCm()); + if (a.getWeightKg() != null) anthropometry.put("weight", a.getWeightKg()); + if (a.getBmi() != null) anthropometry.put("bmi", a.getBmi()); + } + if (!vitalList.isEmpty()) { + BenPhysicalVitalDetail v = vitalList.get(0); + if (v.getTemperature() != null) anthropometry.put("temperatureValue", v.getTemperature()); + if (v.getPulseRate() != null) anthropometry.put("pulseRate", v.getPulseRate()); + if (v.getSystolicBP() != null) anthropometry.put("systolicBP", v.getSystolicBP()); + if (v.getDiastolicBP() != null) anthropometry.put("diastolicBP", v.getDiastolicBP()); + if (v.getBloodGlucoseRandom() != null) anthropometry.put("bloodGlucoseRandom", v.getBloodGlucoseRandom()); + } + + // fallback: if no exam saved yet, read from registration otherFields + if (anthropometry.isEmpty() && benDetailsOBJ != null && benDetailsOBJ.getOtherFields() != null) { + try { + Map extraFields = new Gson().fromJson(benDetailsOBJ.getOtherFields(), Map.class); + if (extraFields.containsKey("weight")) anthropometry.put("weight", extraFields.get("weight")); + if (extraFields.containsKey("height")) anthropometry.put("height", extraFields.get("height")); + if (extraFields.containsKey("bmi")) anthropometry.put("bmi", extraFields.get("bmi")); + if (extraFields.containsKey("temperatureValue")) anthropometry.put("temperatureValue", extraFields.get("temperatureValue")); + } catch (Exception ex) { + logger.warn("Could not parse otherFields for fallback anthropometry: " + benDetailsOBJ.getBeneficiaryDetailsId()); + } + } + if (!anthropometry.isEmpty()) resultMap.put("anthropometry", anthropometry); + } + + // Stop TB fields from ExtraFields Map stopTBDetails = new HashMap<>(); if (benDetailsOBJ != null && benDetailsOBJ.getOtherFields() != null) { try { Map extraFields = new Gson().fromJson(benDetailsOBJ.getOtherFields(), Map.class); - Map anthropometry = new HashMap<>(); - if (extraFields.containsKey("weight")) anthropometry.put("weight", extraFields.get("weight")); - if (extraFields.containsKey("height")) anthropometry.put("height", extraFields.get("height")); - if (extraFields.containsKey("bmi")) anthropometry.put("bmi", extraFields.get("bmi")); - if (extraFields.containsKey("temperatureValue")) anthropometry.put("temperatureValue", extraFields.get("temperatureValue")); - if (!anthropometry.isEmpty()) resultMap.put("anthropometry", anthropometry); if (extraFields.containsKey("personFrom")) stopTBDetails.put("personFrom", extraFields.get("personFrom")); if (extraFields.containsKey("caseFindingType")) stopTBDetails.put("caseFindingType", extraFields.get("caseFindingType")); From c7806cf929bb1db568d0dca3f24e5ab1c19e746f Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 30 Jun 2026 15:29:23 +0530 Subject: [PATCH 21/47] fix(compile): rename variable 'a' to 'anthro' to avoid conflict with outer loop variable Co-Authored-By: Claude Sonnet 4.6 --- .../service/impl/BeneficiaryServiceImpl.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index 016576d5..a0c5d3b0 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -520,18 +520,18 @@ private String getMappingsForAddressIDs(List addressLi Map anthropometry = new HashMap<>(); if (!anthroList.isEmpty()) { - BenAnthropometryDetail a = anthroList.get(0); - if (a.getHeightCm() != null) anthropometry.put("height", a.getHeightCm()); - if (a.getWeightKg() != null) anthropometry.put("weight", a.getWeightKg()); - if (a.getBmi() != null) anthropometry.put("bmi", a.getBmi()); + BenAnthropometryDetail anthro = anthroList.get(0); + if (anthro.getHeightCm() != null) anthropometry.put("height", anthro.getHeightCm()); + if (anthro.getWeightKg() != null) anthropometry.put("weight", anthro.getWeightKg()); + if (anthro.getBmi() != null) anthropometry.put("bmi", anthro.getBmi()); } if (!vitalList.isEmpty()) { - BenPhysicalVitalDetail v = vitalList.get(0); - if (v.getTemperature() != null) anthropometry.put("temperatureValue", v.getTemperature()); - if (v.getPulseRate() != null) anthropometry.put("pulseRate", v.getPulseRate()); - if (v.getSystolicBP() != null) anthropometry.put("systolicBP", v.getSystolicBP()); - if (v.getDiastolicBP() != null) anthropometry.put("diastolicBP", v.getDiastolicBP()); - if (v.getBloodGlucoseRandom() != null) anthropometry.put("bloodGlucoseRandom", v.getBloodGlucoseRandom()); + BenPhysicalVitalDetail vital = vitalList.get(0); + if (vital.getTemperature() != null) anthropometry.put("temperatureValue", vital.getTemperature()); + if (vital.getPulseRate() != null) anthropometry.put("pulseRate", vital.getPulseRate()); + if (vital.getSystolicBP() != null) anthropometry.put("systolicBP", vital.getSystolicBP()); + if (vital.getDiastolicBP() != null) anthropometry.put("diastolicBP", vital.getDiastolicBP()); + if (vital.getBloodGlucoseRandom() != null) anthropometry.put("bloodGlucoseRandom", vital.getBloodGlucoseRandom()); } // fallback: if no exam saved yet, read from registration otherFields From d8008c336f7fdd0aaf4bc902c48f629492fbde0e Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 30 Jun 2026 19:15:03 +0530 Subject: [PATCH 22/47] fix(stoptb): set CreatedDate on t_benvisitdetail insert to prevent NOT NULL constraint violation Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java index a54ebe4f..7a34bd34 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java @@ -38,6 +38,7 @@ public BenVisitDetail getOrCreateVisitForToday(Long beneficiaryRegID, Integer pr visit.setVisitDateTime(new Timestamp(System.currentTimeMillis())); visit.setProviderServiceMapID(providerServiceMapID); visit.setCreatedBy(createdBy); + visit.setCreatedDate(new Timestamp(System.currentTimeMillis())); visit.setVanId(vanID); visit.setParkingPlaceId(parkingPlaceID); visit.setDeleted(false); From 095f0aeb30983c0f5d820f80b1346fd933fd4537 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Tue, 30 Jun 2026 20:00:56 +0530 Subject: [PATCH 23/47] fix(anc-pnc): guard CreatedDate on BenVisitDetail insert for ANC and PNC flows If mobile does not send createdDate, ModelMapper leaves it null and MySQL rejects the insert with NOT NULL constraint. Set server timestamp as fallback. Co-Authored-By: Claude Sonnet 4.6 --- .../com/iemr/flw/service/impl/MaternalHealthServiceImpl.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/iemr/flw/service/impl/MaternalHealthServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/MaternalHealthServiceImpl.java index 52de9aa6..505aac1a 100644 --- a/src/main/java/com/iemr/flw/service/impl/MaternalHealthServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/MaternalHealthServiceImpl.java @@ -205,6 +205,8 @@ public String saveANCVisit(List ancVisitDTOs,Integer userId) { benVisitDetail.setModifiedBy(it.getUpdatedBy()); benVisitDetail.setLastModDate(it.getUpdatedDate()); benVisitDetail.setProviderServiceMapID(it.getProviderServiceMapID()); + if (benVisitDetail.getCreatedDate() == null) + benVisitDetail.setCreatedDate(new java.sql.Timestamp(System.currentTimeMillis())); logger.info("Saving BenVisitDetail"); @@ -406,6 +408,8 @@ public String savePNCVisit(List pncVisitDTOs) { benVisitDetail.setProcessed("N"); benVisitDetail.setModifiedBy(it.getUpdatedBy()); benVisitDetail.setLastModDate(it.getUpdatedDate()); + if (benVisitDetail.getCreatedDate() == null) + benVisitDetail.setCreatedDate(new java.sql.Timestamp(System.currentTimeMillis())); benVisitDetail = benVisitDetailsRepo.save(benVisitDetail); // Saving Data in AncCare table From 3b3a4c5130338d93511f60bd0256a08106651dec Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Wed, 1 Jul 2026 09:17:53 +0530 Subject: [PATCH 24/47] fix(docker): set TZ=Asia/Kolkata to prevent UTC/IST date mismatch Without explicit timezone, Docker defaults to UTC. This causes LocalDate.now() in getOrCreateVisitForToday() to use UTC date, so evening IST registrations appear as next-day July 1 in DB and visit lookups fail after UTC midnight. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index f99697fa..417c00e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ FROM eclipse-temurin:17-jre WORKDIR /app +ENV TZ=Asia/Kolkata + # Copy the built WAR file from the build stage COPY --from=build /app/target/*.war app.war From b1d28ae3ae926bf4a58f31b31efeff477cd93f20 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Wed, 1 Jul 2026 17:35:47 +0530 Subject: [PATCH 25/47] fix(stoptb): handle ISO 8601 Z suffix in parseDob to fix beneficiary age display Timestamp.valueOf() throws on "yyyy-MM-dd HH:mm:ss.SSSZ" format because the trailing Z is not a valid nanosecond value. Strip Z before parsing so mobile DOB like "1987-07-01T00:00:00.000Z" is correctly stored in i_ben_flow_outreach.ben_dob. Co-Authored-By: Claude Sonnet 4.6 --- src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index 2098599b..b60b53c0 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -985,7 +985,7 @@ private void writePrescription(StopTBGeneralOpd opd, Long beneficiaryRegID, BenV private Timestamp parseDob(JsonObject obj) { if (!obj.has("dob") || obj.get("dob").isJsonNull()) return null; try { return new Timestamp(obj.get("dob").getAsLong()); } catch (Exception ignored) {} - try { return Timestamp.valueOf(obj.get("dob").getAsString().replace("T", " ")); } catch (Exception ignored) {} + try { return Timestamp.valueOf(obj.get("dob").getAsString().replace("T", " ").replace("Z", "").trim()); } catch (Exception ignored) {} return null; } From 6d92f74920b663bce8e5fbd17e1153add0c92391 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Wed, 1 Jul 2026 17:56:06 +0530 Subject: [PATCH 26/47] fix(stoptb): fallback to i_ben_flow_outreach.ben_dob when i_beneficiarydetails.dob is null Identity-API mapper (@Mapping source=dto.dob) is commented out, causing dob to be null for all mobile registrations. Fall back to ben_dob from i_ben_flow_outreach so getBeneficiaryData returns correct DOB and mobile shows correct age. Co-Authored-By: Claude Sonnet 4.6 --- .../iemr/flw/service/impl/BeneficiaryServiceImpl.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index a0c5d3b0..d1f9b354 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -380,8 +380,15 @@ private String getMappingsForAddressIDs(List addressLi if (benContactOBJ.getPreferredPhoneNum() != null) benDetailsRMNCH_OBJ.setContact_number(benContactOBJ.getPreferredPhoneNum()); - if (benDetailsOBJ.getDob() != null) + if (benDetailsOBJ.getDob() != null) { benDetailsRMNCH_OBJ.setDob(benDetailsOBJ.getDob()); + } else { + // i_beneficiarydetails.dob is null for Stop TB mobile registrations + // (Identity-API mapper commented out) — fall back to i_ben_flow_outreach.ben_dob + List flows = benFlowStatusRepo.findByBeneficiaryRegID(m.getBenRegId().longValue()); + if (!flows.isEmpty() && flows.get(0).getDob() != null) + benDetailsRMNCH_OBJ.setDob(flows.get(0).getDob()); + } if (benDetailsOBJ.getFatherName() != null) benDetailsRMNCH_OBJ.setFatherName(benDetailsOBJ.getFatherName()); if (benDetailsOBJ.getFirstName() != null) From 7459c478d9c0c9a26dca812f33d1fb328b23198e Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Wed, 1 Jul 2026 18:51:19 +0530 Subject: [PATCH 27/47] fix: use saveAndFlush to prevent FK violation in t_benchiefcomplaint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save() leaves VisitCode UPDATE pending in JPA session — t_benchiefcomplaint INSERT runs before the UPDATE reaches DB, causing FK constraint failure and full transaction rollback. saveAndFlush forces the UPDATE to DB immediately. Co-Authored-By: Claude Sonnet 4.6 --- .../com/iemr/flw/service/impl/TBStopVisitServiceImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java index 7a34bd34..4f36b8b4 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBStopVisitServiceImpl.java @@ -48,7 +48,9 @@ public BenVisitDetail getOrCreateVisitForToday(Long beneficiaryRegID, Integer pr // VisitCode: sessionID(1) + vanID(5-digit) + benVisitId(8-digit) — MMU formula long visitCode = generateVisitCode(visit.getBenVisitId(), vanID != null ? vanID : 1); visit.setVisitCode(visitCode); - return benVisitDetailsRepo.save(visit); + // saveAndFlush ensures the VisitCode UPDATE reaches DB before child tables (e.g. t_benchiefcomplaint) + // insert with FK on VisitCode — same transaction, lazy flush otherwise causes FK violation + return benVisitDetailsRepo.saveAndFlush(visit); } private long generateVisitCode(long visitId, int vanID) { From 9fccf6f43a6959305341cbbb7804aa0f66ce1f47 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 3 Jul 2026 10:16:06 +0530 Subject: [PATCH 28/47] fix(stoptb): compute age from benDetailsRMNCH_OBJ.dob instead of benDetailsOBJ.dob benDetailsOBJ.dob (i_beneficiarydetails) is null for Stop TB mobile registrations, which is why c22b3fd2 added a fallback that fills benDetailsRMNCH_OBJ.dob from i_ben_flow_outreach. The age calculation block still read the original, still-null benDetailsOBJ.dob, so dob came back correct but age/ageFull stayed 0/"". Point it at the already-corrected benDetailsRMNCH_OBJ.dob instead. Co-Authored-By: Claude Sonnet 5 --- .../com/iemr/flw/service/impl/BeneficiaryServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index d1f9b354..c4c1dc12 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -431,9 +431,9 @@ private String getMappingsForAddressIDs(List addressLi String ageDetails = ""; int age_val = 0; String ageUnit = null; - if (benDetailsOBJ.getDob() != null) { + if (benDetailsRMNCH_OBJ.getDob() != null) { - Date date = new Date(benDetailsOBJ.getDob().getTime()); + Date date = new Date(benDetailsRMNCH_OBJ.getDob().getTime()); Calendar cal = Calendar.getInstance(); cal.setTime(date); From 7ddde0a9be3c5f7d872542933a36f0c9562e8ef3 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 3 Jul 2026 14:50:42 +0530 Subject: [PATCH 29/47] fix(stoptb): scope Suspected/Confirmed getAll by facility+village instead of userId Nurse and Counsellor are different logins hitting the same /tb/suspected/getAll and /tb/confirmed/getAll endpoints, but both queries filtered by the calling user's own userId. So a Counsellor pulling data after a Nurse creates a Suspected/Confirmed record saw none of it - only rows tied to their own login. Adds an optional providerServiceMapID(+villageID)-scoped query path on both endpoints, joined through the already-populated BenFlowStatus table (same pattern as StopTBDiagnosticsRepo), so any role at the same facility/village sees the same worklist. The old userId-based path is kept as the fallback when the new fields aren't sent, so already-deployed app builds are unaffected. --- .../java/com/iemr/flw/controller/TBController.java | 9 ++++++--- .../flw/repo/iemr/TBConfirmedTreatmentRepository.java | 4 ++++ .../java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java | 6 ++++++ .../com/iemr/flw/service/TBConfirmedCaseService.java | 2 ++ .../flw/service/impl/TBConfirmedCaseServiceImpl.java | 10 ++++++++++ .../iemr/flw/service/impl/TBSuspectedServiceImpl.java | 5 ++++- 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/iemr/flw/controller/TBController.java b/src/main/java/com/iemr/flw/controller/TBController.java index e95c3ab4..becc387a 100644 --- a/src/main/java/com/iemr/flw/controller/TBController.java +++ b/src/main/java/com/iemr/flw/controller/TBController.java @@ -168,13 +168,16 @@ public String saveConfirmedCase( @Operation(summary = "get tb confirmed case by beneficiary id") @RequestMapping(value = { "/confirmed/getAll" }, method = { RequestMethod.GET }) public String getConfirmedByBenId( - @RequestHeader(value = "jwtToken") String token) { + @RequestHeader(value = "jwtToken") String token, + @RequestParam(required = false) Integer providerServiceMapID, + @RequestParam(required = false) Integer villageID) { OutputResponse response = new OutputResponse(); try { - String result = - tbConfirmedCaseService.getByUserId(token); + String result = providerServiceMapID != null + ? tbConfirmedCaseService.getByProviderServiceMapId(providerServiceMapID, villageID) + : tbConfirmedCaseService.getByUserId(token); if (result != null) response.setResponse(result); diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java b/src/main/java/com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java index 21c941ff..d333a913 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java +++ b/src/main/java/com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java @@ -19,6 +19,10 @@ public interface TBConfirmedTreatmentRepository List findByUserId(Integer benId); List findByBenIdAndVisitCode(Long benId, Long visitCode); + @Query("SELECT tc FROM TBConfirmedCase tc WHERE tc.benId IN " + + "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId)") + List getByProviderServiceMapIdAndVillageId(@Param("psmId") Integer psmId, @Param("villageId") Integer villageId); + @Transactional @Modifying @Query("UPDATE TBConfirmedCase t SET t.vanSerialNo = t.id WHERE t.id = :id") diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java b/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java index ef3a2222..c7ca3066 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java @@ -27,6 +27,12 @@ TBSuspected getByUserIdAndBenIdAndVisitCode(@Param("benId") Long benId, @Param(" @Query("SELECT tbs FROM TBSuspected tbs WHERE tbs.userId = :userId and tbs.visitDate >= :fromDate and tbs.visitDate <= :toDate") List getByUserId(@Param("userId") Integer userId, @Param("fromDate") Timestamp fromDate, @Param("toDate") Timestamp toDate); + @Query("SELECT tbs FROM TBSuspected tbs WHERE tbs.benId IN " + + "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId) " + + "AND tbs.visitDate >= :fromDate and tbs.visitDate <= :toDate") + List getByProviderServiceMapIdAndVillageId(@Param("psmId") Integer psmId, @Param("villageId") Integer villageId, + @Param("fromDate") Timestamp fromDate, @Param("toDate") Timestamp toDate); + @Transactional @Modifying @Query("UPDATE TBSuspected t SET t.vanSerialNo = t.id WHERE t.id = :id") diff --git a/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java b/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java index 64505b79..6471f799 100644 --- a/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java +++ b/src/main/java/com/iemr/flw/service/TBConfirmedCaseService.java @@ -13,4 +13,6 @@ public interface TBConfirmedCaseService { String getByBenId(Long benId, String authorisation) throws Exception; String getByUserId(String authorisation) throws Exception; + + String getByProviderServiceMapId(Integer providerServiceMapID, Integer villageID) throws Exception; } diff --git a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java index a38073d6..a2785825 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBConfirmedCaseServiceImpl.java @@ -145,6 +145,16 @@ public String getByBenId(Long benId, String authorisation) throws Exception { public String getByUserId(String authorisation) throws Exception { Integer userId = jwtUtil.extractUserId(authorisation); List list = repository.findByUserId(userId); + return buildTbConfirmedCasesResponse(userId, list); + } + + @Override + public String getByProviderServiceMapId(Integer providerServiceMapID, Integer villageID) throws Exception { + List list = repository.getByProviderServiceMapIdAndVillageId(providerServiceMapID, villageID); + return buildTbConfirmedCasesResponse(null, list); + } + + private String buildTbConfirmedCasesResponse(Integer userId, List list) { List dtoList = list.stream().map(this::toDTO).collect(Collectors.toList()); List benIds = dtoList.stream().map(TBConfirmedCaseDTO::getBenId).collect(Collectors.toList()); diff --git a/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java index d675a01d..24dd63fb 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java @@ -71,7 +71,10 @@ public String save(TBSuspectedRequestDTO requestDTO) throws Exception { @Override public String getByUserId(GetBenRequestHandler request) { List dtos = new ArrayList<>(); - List tbSuspectedList = tbSuspectedRepo.getByUserId(request.getAshaId(), request.getFromDate(), request.getToDate()); + List tbSuspectedList = request.getProviderServiceMapID() != null + ? tbSuspectedRepo.getByProviderServiceMapIdAndVillageId(request.getProviderServiceMapID(), request.getVillageID(), + request.getFromDate(), request.getToDate()) + : tbSuspectedRepo.getByUserId(request.getAshaId(), request.getFromDate(), request.getToDate()); for (TBSuspected tbSuspected : tbSuspectedList) { TBSuspectedDTO dto = modelMapper.map(tbSuspected, TBSuspectedDTO.class); dto.setUpdateDate(tbSuspected.getLastModDate()); From 33c4f4d590ebdf4f60ecc5083d8c7f143af66160 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 3 Jul 2026 15:07:04 +0530 Subject: [PATCH 30/47] fix(stoptb): drop date filter from facility-scoped Suspected query visitDate isn't the right dimension for a facility worklist - an unresolved Suspected case from months ago is still actionable, and filtering it out by date would hide it from the Counsellor, working against the visibility fix in the previous commit. Also matches StopTBDiagnosticsRepo's facility-scoped query, which never date-filters. Old ashaId-based fallback path is untouched and still date-filters as before. --- src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java | 6 ++---- .../com/iemr/flw/service/impl/TBSuspectedServiceImpl.java | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java b/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java index c7ca3066..c7b3ab35 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java @@ -28,10 +28,8 @@ TBSuspected getByUserIdAndBenIdAndVisitCode(@Param("benId") Long benId, @Param(" List getByUserId(@Param("userId") Integer userId, @Param("fromDate") Timestamp fromDate, @Param("toDate") Timestamp toDate); @Query("SELECT tbs FROM TBSuspected tbs WHERE tbs.benId IN " + - "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId) " + - "AND tbs.visitDate >= :fromDate and tbs.visitDate <= :toDate") - List getByProviderServiceMapIdAndVillageId(@Param("psmId") Integer psmId, @Param("villageId") Integer villageId, - @Param("fromDate") Timestamp fromDate, @Param("toDate") Timestamp toDate); + "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId)") + List getByProviderServiceMapIdAndVillageId(@Param("psmId") Integer psmId, @Param("villageId") Integer villageId); @Transactional @Modifying diff --git a/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java index 24dd63fb..1f5856a1 100644 --- a/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/TBSuspectedServiceImpl.java @@ -72,8 +72,7 @@ public String save(TBSuspectedRequestDTO requestDTO) throws Exception { public String getByUserId(GetBenRequestHandler request) { List dtos = new ArrayList<>(); List tbSuspectedList = request.getProviderServiceMapID() != null - ? tbSuspectedRepo.getByProviderServiceMapIdAndVillageId(request.getProviderServiceMapID(), request.getVillageID(), - request.getFromDate(), request.getToDate()) + ? tbSuspectedRepo.getByProviderServiceMapIdAndVillageId(request.getProviderServiceMapID(), request.getVillageID()) : tbSuspectedRepo.getByUserId(request.getAshaId(), request.getFromDate(), request.getToDate()); for (TBSuspected tbSuspected : tbSuspectedList) { TBSuspectedDTO dto = modelMapper.map(tbSuspected, TBSuspectedDTO.class); From 349e714496d228a50ecbdd74ecb787c1e90e4d19 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 3 Jul 2026 16:14:11 +0530 Subject: [PATCH 31/47] fix(stoptb): join facility-scoped query on beneficiaryID, not beneficiaryRegID TBSuspected.benId / TBConfirmedCase.benId store the person-level beneficiary_id (e.g. 907797933607), not beneficiaryRegID (e.g. 35137) - confirmed by querying i_ben_flow_outreach directly. The facility-scoped query added in the previous two commits joined against BenFlowStatus.beneficiaryRegID, so it never matched anything and always returned empty. Switched the join to BenFlowStatus.beneficiaryID, which is the correct corresponding column. --- .../com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java | 2 +- src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java b/src/main/java/com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java index d333a913..122890e3 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java +++ b/src/main/java/com/iemr/flw/repo/iemr/TBConfirmedTreatmentRepository.java @@ -20,7 +20,7 @@ public interface TBConfirmedTreatmentRepository List findByBenIdAndVisitCode(Long benId, Long visitCode); @Query("SELECT tc FROM TBConfirmedCase tc WHERE tc.benId IN " + - "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId)") + "(SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId)") List getByProviderServiceMapIdAndVillageId(@Param("psmId") Integer psmId, @Param("villageId") Integer villageId); @Transactional diff --git a/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java b/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java index c7b3ab35..e559bdfb 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/TBSuspectedRepo.java @@ -28,7 +28,7 @@ TBSuspected getByUserIdAndBenIdAndVisitCode(@Param("benId") Long benId, @Param(" List getByUserId(@Param("userId") Integer userId, @Param("fromDate") Timestamp fromDate, @Param("toDate") Timestamp toDate); @Query("SELECT tbs FROM TBSuspected tbs WHERE tbs.benId IN " + - "(SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId)") + "(SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.providerServiceMapId = :psmId AND b.villageID = :villageId)") List getByProviderServiceMapIdAndVillageId(@Param("psmId") Integer psmId, @Param("villageId") Integer villageId); @Transactional From 4d6606ea7df3bf206828eb1bd2ead2a869718a18 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 23 Jun 2026 08:53:41 +0530 Subject: [PATCH 32/47] Added counselled flag to confirmed cases response, indicating whether a beneficiary has completed the TB Counselling form. Implemented using a single bulk query to avoid N+1 lookups against FormResponse. --- .../java/com/iemr/flw/repo/iemr/FormResponseRepo.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java index 66672c16..e902ee94 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java @@ -60,6 +60,14 @@ List findCounselledBenIds(@Param("benIds") List benIds, @Param("formId") Long formId, @Param("status") String status); + @Query("SELECT r.beneficiaryId FROM FormResponse r " + + "WHERE r.beneficiaryId IN :benIds " + + "AND r.formId = :formId " + + "AND r.status = :status") + List findCounselledBenIds(@Param("benIds") List benIds, + @Param("formId") Long formId, + @Param("status") String status); + @Query("SELECT r FROM FormResponse r " + "WHERE r.formId IN :formIds " + "AND r.status = 'SUBMITTED' " From 33219a156ae15a5327dd4b9a7cb06288e517416c Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 26 Jun 2026 13:23:03 +0530 Subject: [PATCH 33/47] Revert "Committed 82b00ccf with only the three files:" This reverts commit 3040a08077d82d27dfd86f29f0fbdd4dc094ec56. --- src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index 50af2b38..281a87d0 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -98,6 +98,6 @@ public class OptionCondition { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason") + @Column(name = "SyncFailureReason", length = 255) private String syncFailureReason; } From 695122192b1cbd9901b692ece35f9fcded4d61f3 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 26 Jun 2026 13:30:30 +0530 Subject: [PATCH 34/47] =?UTF-8?q?Committed=2082b00ccf=20with=20only=20the?= =?UTF-8?q?=20three=20files:=20=20=20-=20BeneficiaryServiceImpl.java=20?= =?UTF-8?q?=E2=80=94=20serializeNulls()=20+=20GPS=20fallback=20from=20addr?= =?UTF-8?q?ess=20=20=20-=20RMNCHBeneficiaryDetailsRmnch.java=20=E2=80=94?= =?UTF-8?q?=20gpsLatitude,=20gpsLongitude,=20digipin,=20gpsTimestamp,=20is?= =?UTF-8?q?GpsUnavailable=20fields=20=20=20-=20RMNCHHouseHoldDetails.java?= =?UTF-8?q?=20=E2=80=94=20gpsLatitude,=20gpsLongitude=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index 281a87d0..50af2b38 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -98,6 +98,6 @@ public class OptionCondition { @Column(name = "Syncedby", length = 50) private String syncedBy; - @Column(name = "SyncFailureReason", length = 255) + @Column(name = "SyncFailureReason") private String syncFailureReason; } From 5df65845ca04ce5047e9345a7cad05d3e167fc4c Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Wed, 15 Jul 2026 18:55:08 +0530 Subject: [PATCH 35/47] Integrated diagnostic device (X-ray/CAD/Trunat) results and document storage with the diagnostic order workflow --- src/main/environment/common_ci.properties | 21 ++ src/main/environment/common_docker.properties | 20 ++ .../environment/common_example.properties | 22 +- src/main/environment/common_local.properties | 25 +- .../DiagnosticDocumentController.java | 54 ++++ .../controller/DiagnosticOrderController.java | 112 ++++++++ .../flw/domain/iemr/DiagnosticDocument.java | 84 ++++++ .../iemr/flw/domain/iemr/DiagnosticOrder.java | 103 +++++++ .../domain/iemr/DiagnosticProviderToken.java | 40 +++ .../flw/domain/iemr/DiagnosticResult.java | 57 ++++ .../flw/dto/DiagnosticDocumentContent.java | 11 + .../flw/dto/DiagnosticOrderRequestDto.java | 42 +++ .../flw/dto/DiagnosticOrderResultDto.java | 18 ++ .../provider/DiagnosticDocumentAsset.java | 21 ++ .../provider/DiagnosticPollResult.java | 22 ++ .../provider/DiagnosticProvider.java | 12 + .../provider/DiagnosticProviderFactory.java | 32 +++ .../provider/DiagnosticPushResult.java | 15 + .../emrlite/EmrLiteAuthInterceptor.java | 64 +++++ .../provider/emrlite/EmrLiteProvider.java | 264 +++++++++++++++++ .../emrlite/EmrLiteRestTemplateConfig.java | 21 ++ .../provider/emrlite/EmrLiteTokenManager.java | 243 ++++++++++++++++ .../emrlite/dto/EmrLiteCadRawJson.java | 33 +++ .../emrlite/dto/EmrLiteLoginRequest.java | 13 + .../emrlite/dto/EmrLiteLoginResponse.java | 11 + .../emrlite/dto/EmrLiteOrderRequest.java | 28 ++ .../emrlite/dto/EmrLiteOrderResponse.java | 14 + .../emrlite/dto/EmrLiteProviderResponse.java | 20 ++ .../emrlite/dto/EmrLiteRefreshRequest.java | 12 + .../emrlite/dto/EmrLiteRefreshResponse.java | 11 + .../emrlite/dto/EmrLiteResultRequest.java | 19 ++ .../emrlite/dto/EmrLiteResultResponse.java | 43 +++ .../masterEnum/DiagnosticDocumentType.java | 18 ++ .../flw/masterEnum/DiagnosticOrderStatus.java | 19 ++ .../flw/masterEnum/DiagnosticOrderType.java | 40 +++ .../masterEnum/DiagnosticProviderCode.java | 10 + .../flw/repo/iemr/DiagnosticDocumentRepo.java | 13 + .../flw/repo/iemr/DiagnosticOrderRepo.java | 40 +++ .../iemr/DiagnosticProviderTokenRepo.java | 13 + .../flw/repo/iemr/DiagnosticResultRepo.java | 13 + .../iemr/flw/repo/iemr/FormResponseRepo.java | 8 - .../service/DiagnosticDocumentService.java | 13 + .../flw/service/DiagnosticOrderService.java | 27 ++ .../impl/DiagnosticDocumentServiceImpl.java | 155 ++++++++++ .../impl/DiagnosticOrderServiceImpl.java | 270 ++++++++++++++++++ .../impl/DiagnosticPollSchedulerService.java | 117 ++++++++ 46 files changed, 2252 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java create mode 100644 src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java create mode 100644 src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java create mode 100644 src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java create mode 100644 src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java create mode 100644 src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java create mode 100644 src/main/java/com/iemr/flw/service/DiagnosticOrderService.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 36c26079..33462845 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -40,3 +40,24 @@ sms-password=@env.SMS_PASSWORD@ send-message-url=@env.SMS_MESSAGE_URL@ #crash.logs.base.path=@env.CRASH_LOGS_PATH@ + +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=@env.DIAGNOSTIC_ACTIVE_PROVIDER@ + +diagnostic.emrlite.base-url=@env.DIAGNOSTIC_EMRLITE_BASE_URL@ +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=@env.DIAGNOSTIC_EMRLITE_USERNAME@ +diagnostic.emrlite.password=@env.DIAGNOSTIC_EMRLITE_PASSWORD@ +diagnostic.emrlite.token-ttl-seconds=@env.DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS@ +diagnostic.emrlite.refresh-ttl-seconds=@env.DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS@ + +diagnostic.poll.tick-ms=@env.DIAGNOSTIC_POLL_TICK_MS@ +diagnostic.poll.give-up-minutes=@env.DIAGNOSTIC_POLL_GIVE_UP_MINUTES@ +diagnostic.poll.interval-seconds=@env.DIAGNOSTIC_POLL_INTERVAL_SECONDS@ +diagnostic.poll.truenat.initial-delay-minutes=@env.DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES@ + +diagnostic.documents.storage-root=@env.DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 52e09b89..e42a437e 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -40,5 +40,25 @@ send-message-url=${SMS_MESSAGE_URL} #crash.logs.base.path=${CRASH_LOGS_PATH} +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} + +diagnostic.poll.tick-ms=${DIAGNOSTIC_POLL_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index cf40a3da..8783b16e 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -44,7 +44,25 @@ send-message-url= crash.logs.base.path= - - +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} + +diagnostic.poll.tick-ms=${DIAGNOSTIC_POLL_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/environment/common_local.properties b/src/main/environment/common_local.properties index f4c0261e..e2f8066c 100644 --- a/src/main/environment/common_local.properties +++ b/src/main/environment/common_local.properties @@ -71,4 +71,27 @@ sms-password= send-message-url= crash.logs.base.path=logs/ -stoptb.enforce.vanid = false \ No newline at end of file +stoptb.enforce.vanid = false + +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} + +diagnostic.poll.tick-ms=${DIAGNOSTIC_POLL_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} + + diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java new file mode 100644 index 00000000..791040c2 --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java @@ -0,0 +1,54 @@ +package com.iemr.flw.controller; + +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; +import com.iemr.flw.service.DiagnosticDocumentService; +import io.swagger.v3.oas.annotations.Operation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * SECURITY: this endpoint carries decrypted document bytes and MUST sit behind proper + * authentication/authorization in the real deployment. Today that's this app's existing + * JwtUserIdValidationFilter (utils/JwtUserIdValidationFilter.java), which protects every + * non-whitelisted path by default - do NOT add "/documents" to security.public-paths. + */ +@RestController +@RequestMapping("/documents") +public class DiagnosticDocumentController { + + private final Logger logger = LoggerFactory.getLogger(DiagnosticDocumentController.class); + + private final DiagnosticDocumentService diagnosticDocumentService; + + public DiagnosticDocumentController(DiagnosticDocumentService diagnosticDocumentService) { + this.diagnosticDocumentService = diagnosticDocumentService; + } + + // Returns raw bytes with a real Content-Type header instead of this app's usual + // OutputResponse-wrapped-JSON-string, since the payload here is genuinely binary + // (a PDF/image), not JSON data. + @GetMapping + @Operation(summary = "Fetch the most recent stored document for a beneficiary + document type (XRAY_CHEST or CAD)") + public ResponseEntity getDocument(@RequestParam Long benId, @RequestParam DiagnosticDocumentType documentType) { + try { + DiagnosticDocumentContent content = diagnosticDocumentService.fetch(benId, documentType); + MediaType mediaType; + try { + mediaType = MediaType.parseMediaType(content.getContentType()); + } catch (Exception e) { + mediaType = MediaType.APPLICATION_PDF; + } + return ResponseEntity.ok().contentType(mediaType).body(content.getContent()); + } catch (Exception e) { + logger.error("Error fetching document: benId={}, documentType={}: {}", benId, documentType, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } +} diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java new file mode 100644 index 00000000..3cd509dd --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -0,0 +1,112 @@ +package com.iemr.flw.controller; + +import com.google.gson.Gson; +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.utils.response.OutputResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/diagnostic") +public class DiagnosticOrderController { + + private final Logger logger = LoggerFactory.getLogger(DiagnosticOrderController.class); + + private final DiagnosticOrderService diagnosticOrderService; + + public DiagnosticOrderController(DiagnosticOrderService diagnosticOrderService) { + this.diagnosticOrderService = diagnosticOrderService; + } + + @PostMapping("/order/push") + @Operation(summary = "Push a diagnostic order to the configured provider") + public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.createAndPushOrder(request); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in pushOrder: {}", e.getMessage()); + response.setError(5000, "Error pushing diagnostic order: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/get") + @Operation(summary = "Get the diagnostic order for a beneficiary+orderType (latest, if more than one exists)") + public String getOrder(@RequestParam Long benRegID, @RequestParam String orderType) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.getOrder(benRegID, orderType); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in getOrder: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic order: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/getByBen/{benRegID}") + @Operation(summary = "Get all diagnostic orders for a beneficiary") + public String getOrdersByBen(@PathVariable Long benRegID) { + OutputResponse response = new OutputResponse(); + try { + List orders = diagnosticOrderService.getOrdersByBenRegId(benRegID); + response.setResponse(new Gson().toJson(orders)); + } catch (Exception e) { + logger.error("Error in getOrdersByBen: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic orders: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/testCompleted") + @Operation(summary = "Mark that the physical test has completed for this beneficiary+orderType, starting the poll cadence") + public String markTestCompleted(@RequestParam Long benRegID, @RequestParam String orderType) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.markTestCompleted(benRegID, orderType); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in markTestCompleted: {}", e.getMessage()); + response.setError(5000, "Error marking test completed: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/result") + @Operation(summary = "Get the diagnostic result for benId+orderType, at whatever stage it's currently in") + public String getOrderResult(@RequestParam Long benId, @RequestParam String orderType) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrderResultDto result = diagnosticOrderService.getOrderResult(benId, orderType); + response.setResponse(new Gson().toJson(result)); + } catch (Exception e) { + logger.error("Error in getOrderResult: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic result: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/poll") + @Operation(summary = "Trigger an immediate poll for one beneficiary+orderType's diagnostic order and return the result (ops use)") + public String pollOrder(@RequestParam Long benRegID, @RequestParam String orderType) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrderResultDto result = diagnosticOrderService.triggerManualPoll(benRegID, orderType); + response.setResponse(new Gson().toJson(result)); + } catch (Exception e) { + logger.error("Error in pollOrder: {}", e.getMessage()); + response.setError(5000, "Error triggering poll: " + e.getMessage()); + } + return response.toString(); + } +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java new file mode 100644 index 00000000..500e0b36 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java @@ -0,0 +1,84 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_document", schema = "db_iemr", + // One row per (beneficiary, documentType) — always the latest of that kind for this + // beneficiary, regardless of which DiagnosticOrder produced it (mirrors the same + // "current state, not history" approach used for DiagnosticResult). + uniqueConstraints = @UniqueConstraint(columnNames = {"ben_reg_id", "document_type"}), + indexes = { + @Index(name = "idx_diagnostic_document_ben_reg_id", columnList = "ben_reg_id"), + @Index(name = "idx_diagnostic_document_order_type", columnList = "order_type"), + @Index(name = "idx_diagnostic_document_epoch_time", columnList = "epoch_time") +}) +@Data +public class DiagnosticDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Informational only — which order most recently (re-)produced this document. Not part of + // the uniqueness key since documents are tracked per beneficiary+type, not per order. + @Column(name = "diagnostic_order_id") + private Long diagnosticOrderId; + + @Column(name = "ben_reg_id") + private Long benRegID; + + // The parent DiagnosticOrder's own orderType (e.g. XRAY_CHEST) — always accurate, unrelated + // to documentType below. + @Column(name = "order_type", length = 20) + private String orderType; + + // Raw provider asset type (e.g. PRIMARY_CAPTURE, REPORT) — kept for audit/debugging. + @Column(name = "asset_type", length = 50) + private String assetType; + + // DiagnosticDocumentType classification (XRAY_CHEST vs CAD), derived from assetType at + // ingest time. This, not orderType/assetType, is the key used for storage and fetch. + @Column(name = "document_type", length = 20) + private String documentType; + + @Column(name = "epoch_time") + private Long epochTime; + + @Column(name = "stored_file_name", length = 150) + private String storedFileName; + + @Column(name = "stored_path", length = 255) + private String storedPath; + + @Column(name = "sha256_hash", length = 64) + private String sha256Hash; + + @Column(name = "content_type", length = 100) + private String contentType; + + @Column(name = "original_file_name", length = 255) + private String originalFileName; + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java new file mode 100644 index 00000000..3d5a88fc --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -0,0 +1,103 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_order", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"ben_reg_id", "visitCode", "order_type"})) +@Data +public class DiagnosticOrder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_event", length = 100) + private String orderEvent; + + @Column(name = "ben_reg_id") + private Long benRegID; + + @Column(name = "provider_service_map_id") + private Integer providerServiceMapID; + + @Column(name = "provider_code", length = 50) + private String providerCode; + + @Column(name = "order_type", length = 20) + private String orderType; + + @Column(name = "external_order_id", unique = true, length = 100) + private String externalOrderId; + + @Column(name = "provider_order_id", length = 100) + private String providerOrderId; + + @Column(name = "status", length = 20) + private String status; + + @Column(name = "retry_count") + private Integer retryCount = 0; + + @Column(name = "last_polled_at") + private Timestamp lastPolledAt; + + @Column(name = "test_completed_at") + private Timestamp testCompletedAt; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "push_response_json", columnDefinition = "LONGTEXT") + private String pushResponseJson; + + @Column(name = "patient_first_name", length = 100) + private String patientFirstName; + + @Column(name = "patient_last_name", length = 100) + private String patientLastName; + + @Column(name = "patient_date_of_birth", length = 10) + private String patientDateOfBirth; + + @Column(name = "patient_sex", length = 10) + private String patientSex; + + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "visitCode") + private Long visitCode; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java new file mode 100644 index 00000000..906f3a57 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java @@ -0,0 +1,40 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_provider_token", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "token_type"})) +@Data +public class DiagnosticProviderToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "provider_code", length = 50, nullable = false) + private String providerCode; + + // "ACCESS", "REFRESH", "API_KEY" — extensible without schema change + @Column(name = "token_type", length = 20, nullable = false) + private String tokenType; + + @Column(name = "token_value", columnDefinition = "TEXT", nullable = false) + private String tokenValue; + + @Column(name = "expires_at") + private Timestamp expiresAt; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java new file mode 100644 index 00000000..a7f9fe3c --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java @@ -0,0 +1,57 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_result", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"diagnostic_order_id"})) +@Data +public class DiagnosticResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "diagnostic_order_id") + private Long diagnosticOrderId; + + @Column(name = "ben_reg_id") + private Long benRegID; + + @Column(name = "provider_status", length = 20) + private String providerStatus; + + @Column(name = "result_summary", columnDefinition = "TEXT") + private String resultSummary; + + @Column(name = "raw_response_json", columnDefinition = "LONGTEXT") + private String rawResponseJson; + + @Column(name = "tb_presence") + private Boolean tbPresence; + + @Column(name = "tb_confidence") + private Double tbConfidence; + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; +} diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java b/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java new file mode 100644 index 00000000..c508e659 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java @@ -0,0 +1,11 @@ +package com.iemr.flw.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class DiagnosticDocumentContent { + private byte[] content; + private String contentType; +} diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java new file mode 100644 index 00000000..7c68c3ef --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -0,0 +1,42 @@ +package com.iemr.flw.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class DiagnosticOrderRequestDto { + + @NotNull + private Long benRegID; + + @NotNull + private Long visitCode; + + @NotNull + private Integer providerServiceMapID; + + @NotNull + private String orderType; + + private String orderEvent; + + @NotNull + @Valid + private PatientDto patient; + + @Data + @NoArgsConstructor + public static class PatientDto { + @NotBlank private String firstName; + @NotBlank private String lastName; + @NotBlank + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "dateOfBirth must be in yyyy-MM-dd format") + private String dateOfBirth; + @NotBlank private String sex; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java new file mode 100644 index 00000000..04f4ead9 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -0,0 +1,18 @@ +package com.iemr.flw.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class DiagnosticOrderResultDto { + + private Long diagnosticOrderId; + private String orderType; + private String status; + private String providerStatus; + private String resultSummary; + private String errorMessage; + private Boolean tbPresence; + private Double tbConfidence; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java new file mode 100644 index 00000000..a43d1d55 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java @@ -0,0 +1,21 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Source-agnostic document payload accepted by DiagnosticDocumentService.ingestAsset(...). + * Not tied to any specific provider's DTO shape (e.g. EMR Lite's AssetDto) or to any + * particular polling flow — deliberately decoupled so it can be fed from wherever the + * caller ends up being wired in. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticDocumentAsset { + private String type; + private String contentType; + private String fileName; + private String base64Content; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java new file mode 100644 index 00000000..97de682f --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java @@ -0,0 +1,22 @@ +package com.iemr.flw.integration.provider; + +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticPollResult { + private DiagnosticOrderStatus status; + private String providerOrderId; + private String resultSummary; + private String rawResponseJson; + private List assets; + private String errorMessage; + private Boolean tbPresence; + private Double tbConfidence; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java new file mode 100644 index 00000000..86bf1cbc --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java @@ -0,0 +1,12 @@ +package com.iemr.flw.integration.provider; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; + +public interface DiagnosticProvider { + + String getProviderCode(); + + DiagnosticPushResult pushOrder(DiagnosticOrder order) throws Exception; + + DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java new file mode 100644 index 00000000..0c92c5bb --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java @@ -0,0 +1,32 @@ +package com.iemr.flw.integration.provider; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class DiagnosticProviderFactory { + + @Autowired + private List providers; + + private Map registry; + + @PostConstruct + public void init() { + registry = providers.stream() + .collect(Collectors.toMap(DiagnosticProvider::getProviderCode, p -> p)); + } + + public DiagnosticProvider getProvider(String providerCode) { + DiagnosticProvider provider = registry.get(providerCode); + if (provider == null) { + throw new IllegalArgumentException("No diagnostic provider registered for code: " + providerCode); + } + return provider; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java new file mode 100644 index 00000000..6920b87d --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java @@ -0,0 +1,15 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticPushResult { + private boolean success; + private String providerOrderId; + private String rawResponseJson; + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java new file mode 100644 index 00000000..eb2e7c36 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java @@ -0,0 +1,64 @@ +package com.iemr.flw.integration.provider.emrlite; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** + * Attaches the current EMR Lite bearer token to every outgoing request and, on a 401, + * forces a token refresh and retries once. Scoped to the emrLiteRestTemplate bean only — + * must not be reused for other providers' RestTemplate instances. + */ +public class EmrLiteAuthInterceptor implements ClientHttpRequestInterceptor { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteAuthInterceptor.class); + + private final EmrLiteTokenManager tokenManager; + + public EmrLiteAuthInterceptor(EmrLiteTokenManager tokenManager) { + this.tokenManager = tokenManager; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) + throws IOException { + request.getHeaders().setBearerAuth(getValidToken()); + ClientHttpResponse response = execution.execute(request, body); + if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) { + logger.warn("Received 401 from EMR Lite, foremrLiteRestTemplatecing token refresh and retrying"); + response.close(); + request.getHeaders().setBearerAuth(forceRefresh()); + return execution.execute(request, body); + } + return response; + } + + private String getValidToken() throws IOException { + try { + return tokenManager.getValidToken(); + } catch (Exception e) { + // Logged here (with full stack trace) because every current caller up the chain + // (DiagnosticOrderServiceImpl, DiagnosticPollSchedulerService) only logs e.getMessage(), + // which would otherwise show just this method's generic wrapper text. Folding the real + // cause into the message too means those shallow logs become useful without having to + // touch every call site. + logger.error("Unable to obtain EMR Lite token", e); + throw new IOException("Unable to obtain EMR Lite token: " + e.getMessage(), e); + } + } + + private String forceRefresh() throws IOException { + try { + return tokenManager.forceRefresh(); + } catch (Exception e) { + logger.error("Unable to refresh EMR Lite token", e); + throw new IOException("Unable to refresh EMR Lite token: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java new file mode 100644 index 00000000..1c266f37 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -0,0 +1,264 @@ +package com.iemr.flw.integration.provider.emrlite; + +import com.google.gson.Gson; +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.integration.provider.DiagnosticProvider; +import com.iemr.flw.integration.provider.DiagnosticPushResult; +import com.iemr.flw.integration.provider.emrlite.dto.*; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.masterEnum.DiagnosticProviderCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class EmrLiteProvider implements DiagnosticProvider { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteProvider.class); + private static final String PROVIDER_CODE = DiagnosticProviderCode.EMRLITE.name(); + private static final String CAD_COMPONENT_KEY = "cad"; + private static final String TUBERCULOSIS_FINDING_NAME = "Tuberculosis"; + + @Value("${diagnostic.emrlite.order-url}") + private String orderUrl; + + @Value("${diagnostic.emrlite.result-url}") + private String resultUrl; + + // No longer used directly here — EmrLiteAuthInterceptor (wired into the emrLiteRestTemplate + // bean via EmrLiteRestTemplateConfig) now owns calling tokenManager.getValidToken()/forceRefresh(). + // Kept commented for reference/rollback alongside postWithAuth(...) below. + // @Autowired + // private EmrLiteTokenManager tokenManager; + + // Superseded by the interceptor-backed emrLiteRestTemplate bean below (EmrLiteAuthInterceptor + // now handles attaching/refreshing the bearer token for every call on this RestTemplate). + // private final RestTemplate restTemplate = new RestTemplate(); + + @Autowired + @Qualifier("emrLiteRestTemplate") + private RestTemplate restTemplate; + + private final Gson gson = new Gson(); + + @Override + public String getProviderCode() { + return PROVIDER_CODE; + } + + @Override + public DiagnosticPushResult pushOrder(DiagnosticOrder order) throws Exception { + EmrLiteOrderRequest request = buildOrderRequest(order); + String responseBody; + try { + // responseBody = postWithAuth(orderUrl, gson.toJson(request)); // superseded by EmrLiteAuthInterceptor + responseBody = doPost(orderUrl, gson.toJson(request)); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + String providerMessage = null; + try { + EmrLiteProviderResponse errorEnvelope = gson.fromJson(body, EmrLiteProviderResponse.class); + if (errorEnvelope != null) { + providerMessage = errorEnvelope.getMessage(); + } + } catch (Exception parseEx) { + logger.debug("Could not parse provider error body as envelope JSON for externalOrderId={}", + order.getExternalOrderId()); + } + String errorMessage = (providerMessage != null && !providerMessage.isBlank()) + ? "HTTP " + e.getStatusCode() + ": " + providerMessage + : "HTTP " + e.getStatusCode(); + logger.warn("Provider HTTP error on order push: externalOrderId={}, status={}, message={}", + order.getExternalOrderId(), e.getStatusCode(), providerMessage); + return new DiagnosticPushResult(false, null, body, errorMessage); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (!envelope.isSuccess()) { + logger.warn("Provider rejected order push: externalOrderId={}, message={}", + order.getExternalOrderId(), envelope.getMessage()); + return new DiagnosticPushResult(false, null, responseBody, envelope.getMessage()); + } + + EmrLiteOrderResponse orderResponse = gson.fromJson(envelope.getData(), EmrLiteOrderResponse.class); + logger.info("Order pushed: externalOrderId={}, status={}", + order.getExternalOrderId(), orderResponse.getStatus()); + // Provider echoes our externalOrderId back; store it as providerOrderId for audit + return new DiagnosticPushResult(true, orderResponse.getExternalOrderId(), responseBody, null); + } + + @Override + public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception { + // Always poll using our own externalOrderId per the API contract + EmrLiteResultRequest request = new EmrLiteResultRequest(order.getExternalOrderId(), includeAssets); + String responseBody; + try { + // responseBody = postWithAuth(resultUrl, gson.toJson(request)); // superseded by EmrLiteAuthInterceptor + responseBody = doPost(resultUrl, gson.toJson(request)); + } catch (HttpClientErrorException.NotFound e) { + // Permanent per the API contract ("does not exist or belongs to a different integration + // client") — return a normal FAILED result instead of throwing, so the poll scheduler + // stops immediately instead of retrying this on the transient exponential back-off schedule. + logger.warn("Provider returned 404 Not Found for externalOrderId={}", order.getExternalOrderId()); + return new DiagnosticPollResult(DiagnosticOrderStatus.FAILED, null, null, + e.getResponseBodyAsString(), null, + "Order not found on provider (404): externalOrderId=" + order.getExternalOrderId(), + null, null); + } + // Other HttpStatusCodeException subtypes (401 surviving the auth-interceptor retry, 5xx, etc.) + // are not caught here and propagate to the scheduler's existing transient-retry-with-backoff path. + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (!envelope.isSuccess()) { + throw new Exception("Provider rejected result poll: " + envelope.getMessage()); + } + + EmrLiteResultResponse result = gson.fromJson(envelope.getData(), EmrLiteResultResponse.class); + DiagnosticOrderStatus status = deriveOrderStatus(result); + + String summary = result.getResult() != null ? result.getResult().getSummary() : null; + List assets = mapAssets(result); + EmrLiteCadRawJson.FindingDto tbFinding = extractTuberculosisFinding(result); + Boolean tbPresence = tbFinding != null ? tbFinding.isPresence() : null; + Double tbConfidence = tbFinding != null ? tbFinding.getConfidence() : null; + + return new DiagnosticPollResult(status, order.getExternalOrderId(), summary, responseBody, assets, null, + tbPresence, tbConfidence); + } + + // The CAD (e.g. DRONGOAI) findings list lives inside rawJson.results.findings, which only + // exists once a "cad" component has run. Deliberately does not touch rawJson.results.image - + // that's an internal provider media path, not a URL or base64 payload we can use; the actual + // X-ray capture (when sent) arrives separately via result.assets, handled by mapAssets() above. + private EmrLiteCadRawJson.FindingDto extractTuberculosisFinding(EmrLiteResultResponse result) { + if (result.getComponents() == null || !result.getComponents().containsKey(CAD_COMPONENT_KEY)) { + return null; + } + if (result.getResult() == null || result.getResult().getRawJson() == null) { + return null; + } + try { + EmrLiteCadRawJson cadRawJson = + gson.fromJson(gson.toJson(result.getResult().getRawJson()), EmrLiteCadRawJson.class); + if (cadRawJson.getResults() == null || cadRawJson.getResults().getFindings() == null) { + return null; + } + return cadRawJson.getResults().getFindings().stream() + .filter(f -> TUBERCULOSIS_FINDING_NAME.equalsIgnoreCase(f.getName())) + .findFirst() + .orElse(null); + } catch (Exception e) { + logger.warn("Failed to parse CAD rawJson findings for externalOrderId={}: {}", + result.getExternalOrderId(), e.getMessage()); + return null; + } + } + + // The provider's top-level "status" is not reliable on its own: a real example response + // ("XRAY Completed with Assets, CAD Pending") shows status=COMPLETED while the cad component + // is still PENDING and the summary literally says "CAD Pending". Derive true completion from + // the components map instead - COMPLETED only when every component is COMPLETED, FAILED if any + // component is FAILED - falling back to the top-level status only for the PENDING/IN_PROGRESS + // distinction (or when the provider sends no components at all). + private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { + Map components = result.getComponents(); + if (components == null || components.isEmpty()) { + return DiagnosticOrderStatus.fromString(result.getStatus()); + } + boolean anyFailed = components.values().stream() + .anyMatch(c -> "FAILED".equalsIgnoreCase(c.getStatus())); + if (anyFailed) { + return DiagnosticOrderStatus.FAILED; + } + boolean allCompleted = components.values().stream() + .allMatch(c -> "COMPLETED".equalsIgnoreCase(c.getStatus())); + if (allCompleted) { + return DiagnosticOrderStatus.COMPLETED; + } + DiagnosticOrderStatus topLevelStatus = DiagnosticOrderStatus.fromString(result.getStatus()); + if (topLevelStatus == DiagnosticOrderStatus.COMPLETED || topLevelStatus == DiagnosticOrderStatus.FAILED) { + // Components contradict a terminal top-level status (e.g. xray COMPLETED, cad PENDING, + // top-level status still says COMPLETED) — only the components map may resolve to a + // terminal state; treat this as still in progress. + return DiagnosticOrderStatus.IN_PROGRESS; + } + return topLevelStatus; + } + + // Assets can be present before the order is fully complete (e.g. the X-ray's PRIMARY_CAPTURE + // is returned while CAD is still PENDING), so this is extracted independently of order status. + private List mapAssets(EmrLiteResultResponse result) { + if (result.getResult() == null || result.getResult().getAssets() == null) { + return new ArrayList<>(); + } + List assets = new ArrayList<>(); + for (EmrLiteResultResponse.AssetDto assetDto : result.getResult().getAssets()) { + assets.add(new DiagnosticDocumentAsset( + assetDto.getType(), assetDto.getContentType(), assetDto.getFileName(), assetDto.getBase64())); + } + return assets; + } + + // Superseded by EmrLiteAuthInterceptor, which now attaches/refreshes the bearer token + // for every call made on the emrLiteRestTemplate bean. Kept for reference/rollback. + // private String postWithAuth(String url, String jsonBody) throws Exception { + // String token = tokenManager.getValidToken(); + // try { + // return doPost(url, jsonBody, token); + // } catch (HttpClientErrorException.Unauthorized e) { + // logger.warn("Received 401 from provider, forcing token refresh and retrying"); + // token = tokenManager.forceRefresh(); + // return doPost(url, jsonBody, token); + // } + // } + // + // private String doPost(String url, String jsonBody, String token) { + // HttpHeaders headers = new HttpHeaders(); + // headers.setContentType(MediaType.APPLICATION_JSON); + // headers.setBearerAuth(token); + // HttpEntity entity = new HttpEntity<>(jsonBody, headers); + // ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + // return response.getBody(); + // } + + private String doPost(String url, String jsonBody) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + return response.getBody(); + } + + private EmrLiteOrderRequest buildOrderRequest(DiagnosticOrder order) { + EmrLiteOrderRequest.PatientDto patient = new EmrLiteOrderRequest.PatientDto( + order.getPatientFirstName(), + order.getPatientLastName(), + order.getPatientDateOfBirth(), + order.getPatientSex()); + String orderedAt = OffsetDateTime.now(ZoneId.of("Asia/Kolkata")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + return new EmrLiteOrderRequest( + String.valueOf(order.getBenRegID()), + String.valueOf(order.getVisitCode()), + order.getExternalOrderId(), + order.getOrderType(), + orderedAt, + patient); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java new file mode 100644 index 00000000..cbc9993c --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java @@ -0,0 +1,21 @@ +package com.iemr.flw.integration.provider.emrlite; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +/** + * Dedicated RestTemplate for EMR Lite diagnostic provider calls only. + * Do NOT reuse this bean for other integrations (TM API, FHIR, etc.) — EmrLiteAuthInterceptor + * unconditionally attaches an EMR Lite bearer token to every request that goes through it. + */ +@Configuration +public class EmrLiteRestTemplateConfig { + + @Bean + public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new EmrLiteAuthInterceptor(emrLiteTokenManager)); + return restTemplate; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java new file mode 100644 index 00000000..08d9518f --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java @@ -0,0 +1,243 @@ +package com.iemr.flw.integration.provider.emrlite; + +import com.google.gson.Gson; +import com.iemr.flw.domain.iemr.DiagnosticProviderToken; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteLoginRequest; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteLoginResponse; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteProviderResponse; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteRefreshRequest; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteRefreshResponse; +import com.iemr.flw.masterEnum.DiagnosticProviderCode; +import com.iemr.flw.repo.iemr.DiagnosticProviderTokenRepo; +import com.iemr.flw.utils.CryptoUtil; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Optional; + +@Component +public class EmrLiteTokenManager { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteTokenManager.class); + private static final String PROVIDER_CODE = DiagnosticProviderCode.EMRLITE.name(); + private static final String TYPE_ACCESS = "ACCESS"; + private static final String TYPE_REFRESH = "REFRESH"; + + @Value("${diagnostic.emrlite.login-url}") + private String loginUrl; + + @Value("${diagnostic.emrlite.refresh-url}") + private String refreshUrl; + + @Value("${diagnostic.emrlite.username}") + private String username; + + @Value("${diagnostic.emrlite.password}") + private String encodedPassword; + + @Value("${diagnostic.emrlite.token-ttl-seconds}") + private long tokenTtlSeconds; + + @Value("${diagnostic.emrlite.refresh-ttl-seconds}") + private long refreshTtlSeconds; + + private String password; + + @Autowired + private DiagnosticProviderTokenRepo tokenRepo; + + @Autowired + private CryptoUtil cryptoUtil; + + private final RestTemplate restTemplate = new RestTemplate(); + private final Gson gson = new Gson(); + + @PostConstruct + private void init() { + this.password = encodedPassword != null && encodedPassword.startsWith("0X10:") + ? new String(Base64.getDecoder().decode(encodedPassword.substring(5))) + : encodedPassword; + } + + public String getValidToken() throws Exception { + Optional record = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_ACCESS); + if (record.isPresent()) { + DiagnosticProviderToken token = record.get(); + boolean expired = token.getExpiresAt() == null + || Instant.now().isAfter(token.getExpiresAt().toInstant().minusSeconds(60)); + if (!expired) { + return cryptoUtil.decrypt(token.getTokenValue()); + } + } + // No token in DB or token is expired — acquire lock to prevent parallel logins/refreshes + synchronized (this) { + Optional recheck = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_ACCESS); + if (recheck.isPresent()) { + DiagnosticProviderToken t = recheck.get(); + boolean stillExpired = t.getExpiresAt() == null + || Instant.now().isAfter(t.getExpiresAt().toInstant().minusSeconds(60)); + if (!stillExpired) { + return cryptoUtil.decrypt(t.getTokenValue()); + } + } + return refreshOrLogin(); + } + } + + public synchronized String forceRefresh() throws Exception { + return refreshOrLogin(); + } + + private String refreshOrLogin() throws Exception { + try { + return refreshAccessToken(); + } catch (Exception e) { + logger.warn("Token refresh failed ({}), falling back to full login", e.getMessage()); + return login(); + } + } + + private String refreshAccessToken() throws Exception { + Optional refreshRecord = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_REFRESH); + if (refreshRecord.isEmpty()) { + throw new Exception("No refresh token in DB — need full login"); + } + DiagnosticProviderToken refreshToken = refreshRecord.get(); + // Same expiry-with-safety-buffer check already applied to the access token in + // getValidToken() — no point sending the provider a refresh token we already know has + // expired; fail fast so refreshOrLogin() falls back to a full login immediately. + boolean refreshExpired = refreshToken.getExpiresAt() == null + || Instant.now().isAfter(refreshToken.getExpiresAt().toInstant().minusSeconds(60)); + if (refreshExpired) { + throw new Exception("Refresh token expired — need full login"); + } + String rawRefresh = cryptoUtil.decrypt(refreshToken.getTokenValue()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(gson.toJson(new EmrLiteRefreshRequest(rawRefresh)), headers); + + ResponseEntity response; + try { + response = restTemplate.exchange(refreshUrl, HttpMethod.POST, entity, String.class); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.warn("EMR Lite refresh rejected by provider: url={}, status={}, body={}", + refreshUrl, e.getStatusCode(), body); + throw new Exception("Refresh token rejected by provider (HTTP " + e.getStatusCode() + ") - " + body, e); + } catch (RestClientException e) { + logger.warn("EMR Lite refresh request failed (network/connection error): url={}", refreshUrl, e); + throw new Exception("EMR Lite refresh request failed: " + e.getMessage(), e); + } + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + logger.warn("EMR Lite refresh failed: url={}, status={}, body={}", + refreshUrl, response.getStatusCode(), response.getBody()); + throw new Exception("Token refresh failed with status: " + response.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(response.getBody(), EmrLiteProviderResponse.class); + if (envelope == null) { + logger.warn("EMR Lite refresh response could not be parsed: url={}, rawBody={}", refreshUrl, response.getBody()); + throw new Exception("EMR Lite refresh response could not be parsed, rawBody=" + response.getBody()); + } + if (!envelope.isSuccess()) { + logger.warn("EMR Lite refresh rejected: url={}, message={}, rawBody={}", + refreshUrl, envelope.getMessage(), response.getBody()); + throw new Exception("Token refresh rejected: " + envelope.getMessage()); + } + + EmrLiteRefreshResponse refreshResponse = gson.fromJson(envelope.getData(), EmrLiteRefreshResponse.class); + if (refreshResponse.getAccess() == null) { + throw new Exception("Refresh response returned null access token"); + } + + persistToken(TYPE_ACCESS, refreshResponse.getAccess(), + Timestamp.from(Instant.now().plus(tokenTtlSeconds, ChronoUnit.SECONDS))); + + logger.info("EMR Lite access token refreshed, TTL {}s", tokenTtlSeconds); + return refreshResponse.getAccess(); + } + + private String login() throws Exception { + logger.info("Attempting EMR Lite login: url={}, username={}", loginUrl, username); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>( + gson.toJson(new EmrLiteLoginRequest(username, password)), headers); + + ResponseEntity response; + try { + response = restTemplate.exchange(loginUrl, HttpMethod.POST, entity, String.class); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.error("EMR Lite login rejected by provider: url={}, status={}, body={}", + loginUrl, e.getStatusCode(), body); + throw new Exception("EMR Lite login failed: HTTP " + e.getStatusCode() + " - " + body, e); + } catch (RestClientException e) { + logger.error("EMR Lite login request failed (network/connection error): url={}", loginUrl, e); + throw new Exception("EMR Lite login request failed: " + e.getMessage(), e); + } + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + logger.error("EMR Lite login failed: url={}, status={}, body={}", + loginUrl, response.getStatusCode(), response.getBody()); + throw new Exception("EMR Lite login failed with status: " + response.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(response.getBody(), EmrLiteProviderResponse.class); + if (envelope == null) { + logger.error("EMR Lite login response could not be parsed: url={}, rawBody={}", loginUrl, response.getBody()); + throw new Exception("EMR Lite login response could not be parsed, rawBody=" + response.getBody()); + } + if (!envelope.isSuccess()) { + logger.error("EMR Lite login rejected: url={}, message={}, rawBody={}", + loginUrl, envelope.getMessage(), response.getBody()); + throw new Exception("EMR Lite login rejected: " + envelope.getMessage()); + } + + EmrLiteLoginResponse tokens = gson.fromJson(envelope.getData(), EmrLiteLoginResponse.class); + if (tokens == null || tokens.getAccessToken() == null) { + logger.error("EMR Lite login returned no accessToken: url={}, rawBody={}", loginUrl, response.getBody()); + throw new Exception("Login returned null accessToken"); + } + + persistToken(TYPE_ACCESS, tokens.getAccessToken(), + Timestamp.from(Instant.now().plus(tokenTtlSeconds, ChronoUnit.SECONDS))); + if (tokens.getRefreshToken() != null) { + persistToken(TYPE_REFRESH, tokens.getRefreshToken(), + Timestamp.from(Instant.now().plus(refreshTtlSeconds, ChronoUnit.SECONDS))); + } + + logger.info("EMR Lite login successful, access TTL {}s, refresh TTL {}s", tokenTtlSeconds, refreshTtlSeconds); + return tokens.getAccessToken(); + } + + private void persistToken(String tokenType, String rawValue, Timestamp expiresAt) throws Exception { + String encrypted = cryptoUtil.encrypt(rawValue); + Optional existing = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, tokenType); + DiagnosticProviderToken record = existing.orElseGet(DiagnosticProviderToken::new); + record.setProviderCode(PROVIDER_CODE); + record.setTokenType(tokenType); + record.setTokenValue(encrypted); + record.setExpiresAt(expiresAt); + tokenRepo.save(record); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java new file mode 100644 index 00000000..a8e98754 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java @@ -0,0 +1,33 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Typed view over EmrLiteResultResponse.ResultDto.rawJson, used only to pull the CAD + * (e.g. DRONGOAI) findings list back out of that otherwise-opaque blob. Only the fields + * needed for that extraction are declared - unmapped JSON fields (image, metadata, ...) + * are ignored by Gson rather than modeled here. + */ +@Data +@NoArgsConstructor +public class EmrLiteCadRawJson { + + private ResultsDto results; + + @Data + @NoArgsConstructor + public static class ResultsDto { + private List findings; + } + + @Data + @NoArgsConstructor + public static class FindingDto { + private String name; + private boolean presence; + private double confidence; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java new file mode 100644 index 00000000..35aa37d8 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteLoginRequest { + private String username; + private String password; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java new file mode 100644 index 00000000..24659e11 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java @@ -0,0 +1,11 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteLoginResponse { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java new file mode 100644 index 00000000..e3b6ae9b --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java @@ -0,0 +1,28 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteOrderRequest { + + private String externalPatientId; + private String externalVisitId; + private String externalOrderId; + private String orderType; + private String orderedAt; + private PatientDto patient; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class PatientDto { + private String firstName; + private String lastName; + private String dateOfBirth; + private String sex; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java new file mode 100644 index 00000000..4a791c38 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java @@ -0,0 +1,14 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteOrderResponse { + private String externalPatientId; + private String externalVisitId; + private String externalOrderId; + private String orderType; + private String status; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java new file mode 100644 index 00000000..6af51112 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java @@ -0,0 +1,20 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import com.google.gson.JsonObject; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Generic envelope returned by every provider endpoint: {"Result":"Success|Failure","Data":{...},"Message":"..."} + */ +@Data +@NoArgsConstructor +public class EmrLiteProviderResponse { + private String Result; + private JsonObject Data; + private String Message; + + public boolean isSuccess() { + return "Success".equalsIgnoreCase(Result); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java new file mode 100644 index 00000000..47fb32c6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java @@ -0,0 +1,12 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteRefreshRequest { + private String refresh; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java new file mode 100644 index 00000000..3a69510e --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java @@ -0,0 +1,11 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteRefreshResponse { + // Field name is "access" (not "accessToken") per the getAccessToken API contract + private String access; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java new file mode 100644 index 00000000..a7dcd706 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java @@ -0,0 +1,19 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteResultRequest { + private String externalOrderId; + // "base64" includes report/image file content in the response's assets array; "none" returns + // status/summary only. Callers pass true only for the one follow-up call made once an order + // resolves COMPLETED - every other poll is a cheap status-only check. + private String includeAssets; + + public EmrLiteResultRequest(String externalOrderId, boolean includeAssets) { + this.externalOrderId = externalOrderId; + this.includeAssets = includeAssets ? "base64" : "none"; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java new file mode 100644 index 00000000..a9b33bb6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java @@ -0,0 +1,43 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +public class EmrLiteResultResponse { + + private String externalOrderId; + private String orderType; + private String status; + private Map components; + private ResultDto result; + + @Data + @NoArgsConstructor + public static class ComponentStatus { + private String status; + private String provider; + } + + @Data + @NoArgsConstructor + public static class ResultDto { + private String summary; + private Object rawJson; + private String reportedAt; + private List assets; + } + + @Data + @NoArgsConstructor + public static class AssetDto { + private String type; + private String contentType; + private String fileName; + private String base64; + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java new file mode 100644 index 00000000..d0da78ca --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java @@ -0,0 +1,18 @@ +package com.iemr.flw.masterEnum; + +/** + * Classifies a stored DiagnosticDocument independently of the parent DiagnosticOrder's own + * orderType (which stays e.g. XRAY_CHEST regardless of which artifact this is). Derived + * automatically from the provider's asset.type at ingest time - callers never pass a raw + * "CAD" string, they just ask for this enum. + */ +public enum DiagnosticDocumentType { + XRAY_CHEST, + CAD; + + private static final String CAD_REPORT_ASSET_TYPE = "REPORT"; + + public static DiagnosticDocumentType fromAssetType(String assetType) { + return CAD_REPORT_ASSET_TYPE.equalsIgnoreCase(assetType) ? CAD : XRAY_CHEST; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java new file mode 100644 index 00000000..89820c24 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -0,0 +1,19 @@ +package com.iemr.flw.masterEnum; + +public enum DiagnosticOrderStatus { + PENDING, + IN_PROGRESS, + COMPLETED, + FAILED, + CANCELLED; + + public static DiagnosticOrderStatus fromString(String value) { + if (value == null) return PENDING; + for (DiagnosticOrderStatus status : values()) { + if (status.name().equalsIgnoreCase(value)) { + return status; + } + } + return PENDING; + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java new file mode 100644 index 00000000..46922326 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java @@ -0,0 +1,40 @@ +package com.iemr.flw.masterEnum; + +public enum DiagnosticOrderType { + XRAY_CHEST(1), + MTB(2), + MTB_PLUS(3), + MDR_RIF(4); + + private final int id; + + DiagnosticOrderType(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public String getCode() { + return this.name(); + } + + public static DiagnosticOrderType fromId(int id) { + for (DiagnosticOrderType type : values()) { + if (type.id == id) { + return type; + } + } + throw new IllegalArgumentException("Invalid DiagnosticOrderType ID: " + id); + } + + public static DiagnosticOrderType fromCode(String code) { + for (DiagnosticOrderType type : values()) { + if (type.name().equalsIgnoreCase(code)) { + return type; + } + } + throw new IllegalArgumentException("Invalid DiagnosticOrderType code: " + code); + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java new file mode 100644 index 00000000..a3a5a644 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java @@ -0,0 +1,10 @@ +package com.iemr.flw.masterEnum; + +/** + * Registered diagnostic device integration providers. Add a new constant here when wiring up a + * new DiagnosticProvider implementation (see DiagnosticProviderFactory) - diagnostic.active-provider + * in application properties must be set to one of these names. + */ +public enum DiagnosticProviderCode { + EMRLITE +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java new file mode 100644 index 00000000..e97a188f --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticDocument; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticDocumentRepo extends JpaRepository { + + Optional findByBenRegIDAndDocumentTypeAndDeletedFalse(Long benRegID, String documentType); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java new file mode 100644 index 00000000..260cebe2 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -0,0 +1,40 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DiagnosticOrderRepo extends JpaRepository { + + Optional findByExternalOrderId(String externalOrderId); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.benRegID = :benRegID AND o.visitCode = :visitCode " + + "AND o.orderType = :orderType AND o.deleted = false") + Optional findByBenRegIDAndVisitCodeAndOrderType(@Param("benRegID") Long benRegID, + @Param("visitCode") Long visitCode, + @Param("orderType") String orderType); + + Optional findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + Long benRegID, String orderType); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.benRegID = :benRegID AND o.deleted = false ORDER BY o.createdDate DESC") + List findByBenRegID(@Param("benRegID") Long benRegID); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.status IN ('PENDING', 'IN_PROGRESS') " + + "AND o.deleted = false AND o.testCompletedAt IS NOT NULL " + + "ORDER BY o.lastPolledAt ASC NULLS FIRST") + List findDueForPoll(); + + @Transactional + @Modifying + @Query("UPDATE DiagnosticOrder o SET o.vanSerialNo = o.id WHERE o.id = :id") + void updateVanSerialNo(@Param("id") Long id); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java new file mode 100644 index 00000000..8f029fd8 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticProviderToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticProviderTokenRepo extends JpaRepository { + + Optional findByProviderCodeAndTokenType(String providerCode, String tokenType); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java new file mode 100644 index 00000000..c8796ff5 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticResult; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticResultRepo extends JpaRepository { + + Optional findByDiagnosticOrderIdAndDeletedFalse(Long diagnosticOrderId); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java index e902ee94..66672c16 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormResponseRepo.java @@ -60,14 +60,6 @@ List findCounselledBenIds(@Param("benIds") List benIds, @Param("formId") Long formId, @Param("status") String status); - @Query("SELECT r.beneficiaryId FROM FormResponse r " + - "WHERE r.beneficiaryId IN :benIds " + - "AND r.formId = :formId " + - "AND r.status = :status") - List findCounselledBenIds(@Param("benIds") List benIds, - @Param("formId") Long formId, - @Param("status") String status); - @Query("SELECT r FROM FormResponse r " + "WHERE r.formId IN :formIds " + "AND r.status = 'SUBMITTED' " diff --git a/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java b/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java new file mode 100644 index 00000000..f771afa6 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java @@ -0,0 +1,13 @@ +package com.iemr.flw.service; + +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; + +public interface DiagnosticDocumentService { + + void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String externalOrderId, + DiagnosticDocumentAsset asset) throws Exception; + + DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType) throws Exception; +} diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java new file mode 100644 index 00000000..24c18525 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -0,0 +1,27 @@ +package com.iemr.flw.service; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.integration.provider.DiagnosticPollResult; + +import java.util.List; + +public interface DiagnosticOrderService { + + DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception; + + DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult result) throws Exception; + + DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception; + + DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType) throws Exception; + + DiagnosticOrder markTestCompleted(Long benRegID, String orderType) throws Exception; + + DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType); + + DiagnosticOrder getOrder(Long benRegID, String orderType) throws Exception; + + List getOrdersByBenRegId(Long benRegID) throws Exception; +} diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java new file mode 100644 index 00000000..18efdaa4 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -0,0 +1,155 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticDocument; +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; +import com.iemr.flw.repo.iemr.DiagnosticDocumentRepo; +import com.iemr.flw.service.DiagnosticDocumentService; +import com.iemr.flw.utils.CryptoUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +@Service +public class DiagnosticDocumentServiceImpl implements DiagnosticDocumentService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticDocumentServiceImpl.class); + private static final String DEFAULT_CONTENT_TYPE = "application/pdf"; + + // App-level encryption here protects against someone reading files directly off a + // live/mounted filesystem. It does NOT replace OS-level full-disk encryption (e.g. LUKS), + // which is what protects against physical theft of the drive - both should be in place. + @Value("${diagnostic.documents.storage-root}") + private String storageRoot; + + @Autowired + private DiagnosticDocumentRepo diagnosticDocumentRepo; + + @Autowired + private CryptoUtil cryptoUtil; + + @Override + public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String externalOrderId, + DiagnosticDocumentAsset asset) throws Exception { + if (asset == null || asset.getBase64Content() == null) { + logger.warn("Skipping document ingest for benRegID={}, orderType={}: empty asset content", + benRegID, orderType); + return; + } + + byte[] originalBytes = Base64.getDecoder().decode(asset.getBase64Content()); + String sha256Hash = sha256Hex(originalBytes); + + // Classified automatically from the provider's asset.type - callers never pass "CAD" + // themselves. One DiagnosticDocument per (beneficiary, documentType) - always the latest + // of that kind, regardless of which order produced it, mirroring the same "current state, + // not history" approach used for DiagnosticResult. The provider resends the full current + // asset list on every poll (e.g. the X-ray's PRIMARY_CAPTURE is included again once CAD + // finishes, even though it's the same image), so skip re-writing when unchanged; a + // differing hash overwrites that type's file with the newer content. + DiagnosticDocumentType documentType = DiagnosticDocumentType.fromAssetType(asset.getType()); + Optional existing = + diagnosticDocumentRepo.findByBenRegIDAndDocumentTypeAndDeletedFalse(benRegID, documentType.name()); + if (existing.isPresent() && sha256Hash.equals(existing.get().getSha256Hash())) { + logger.info("Diagnostic document already stored, skipping duplicate: benRegID={}, documentType={}", + benRegID, documentType); + return; + } + + long epochTime = Instant.now().toEpochMilli(); + String storedFileName = documentType.name() + ".enc"; + String relativeDir = String.valueOf(benRegID); + + // Documents must never touch disk unencrypted, not even briefly: encrypt in memory + // first, then write only the ciphertext. + String base64Original = Base64.getEncoder().encodeToString(originalBytes); + // NOTE: reuses existing app-wide CryptoUtil (AES-128-ECB, hardcoded key) - known weaker-than-ideal + // scheme, tracked as follow-up tech debt, not addressed in this change. + String encryptedPayload = cryptoUtil.encrypt(base64Original); + + Path dir = Paths.get(storageRoot, relativeDir); + Files.createDirectories(dir); + Path filePath = dir.resolve(storedFileName); + Files.write(filePath, encryptedPayload.getBytes(StandardCharsets.UTF_8)); + + DiagnosticDocument document = existing.orElseGet(DiagnosticDocument::new); + document.setDiagnosticOrderId(diagnosticOrderId); + document.setBenRegID(benRegID); + document.setOrderType(orderType); + document.setAssetType(asset.getType()); + document.setDocumentType(documentType.name()); + document.setEpochTime(epochTime); + document.setStoredFileName(storedFileName); + document.setStoredPath(relativeDir + "/" + storedFileName); + document.setSha256Hash(sha256Hash); + document.setContentType(asset.getContentType() != null ? asset.getContentType() : DEFAULT_CONTENT_TYPE); + document.setOriginalFileName(asset.getFileName()); + document.setCreatedBy("SYSTEM"); + try { + diagnosticDocumentRepo.save(document); + } catch (DataIntegrityViolationException dive) { + // Lost an upsert race to a concurrent ingest for the same beneficiary+type (e.g. a + // manual poll racing the scheduler) — the winner already persisted equivalent content. + logger.warn("Lost document upsert race for benRegID={}, documentType={}", benRegID, documentType); + return; + } + + // Audit log: benRegID/orderType/timestamp only - never raw document content or the encryption key. + logger.info("Diagnostic document ingested: benRegID={}, orderType={}, epochTime={}", + benRegID, orderType, epochTime); + } + + @Override + public DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType) throws Exception { + DiagnosticDocument document = diagnosticDocumentRepo + .findByBenRegIDAndDocumentTypeAndDeletedFalse(benRegID, documentType.name()) + .orElseThrow(() -> new Exception( + "No document found for benRegID=" + benRegID + ", documentType=" + documentType)); + + Path filePath = Paths.get(storageRoot, document.getStoredPath()); + String encryptedPayload = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); + + // NOTE: reuses existing app-wide CryptoUtil (AES-128-ECB, hardcoded key) - known weaker-than-ideal + // scheme, tracked as follow-up tech debt, not addressed in this change. + String base64Original = cryptoUtil.decrypt(encryptedPayload); + if (base64Original == null) { + throw new Exception("Failed to decrypt document for benRegID=" + benRegID + ", documentType=" + documentType); + } + byte[] originalBytes = Base64.getDecoder().decode(base64Original); + + String recomputedHash = sha256Hex(originalBytes); + if (!recomputedHash.equals(document.getSha256Hash())) { + throw new Exception( + "Document integrity check failed for benRegID=" + benRegID + ", documentType=" + documentType); + } + + // Audit log: benRegID/documentType/timestamp only - never raw document content or the encryption key. + logger.info("Diagnostic document fetched: benRegID={}, documentType={}, epochTime={}", + benRegID, documentType, document.getEpochTime()); + + return new DiagnosticDocumentContent(originalBytes, document.getContentType()); + } + + private static String sha256Hex(byte[] data) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(String.format(Locale.ROOT, "%02x", b)); + } + return hex.toString(); + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java new file mode 100644 index 00000000..d9fd0473 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -0,0 +1,270 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.domain.iemr.DiagnosticResult; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.integration.provider.DiagnosticProvider; +import com.iemr.flw.integration.provider.DiagnosticProviderFactory; +import com.iemr.flw.integration.provider.DiagnosticPushResult; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.repo.iemr.DiagnosticResultRepo; +import com.iemr.flw.service.DiagnosticDocumentService; +import com.iemr.flw.service.DiagnosticOrderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; + +@Service +public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticOrderServiceImpl.class); + + @Value("${diagnostic.active-provider}") + private String activeProvider; + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private DiagnosticResultRepo diagnosticResultRepo; + + @Autowired + private DiagnosticProviderFactory providerFactory; + + @Autowired + private DiagnosticDocumentService diagnosticDocumentService; + + @Override + public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { + Long benRegID = request.getBenRegID(); + Long visitCode = request.getVisitCode(); + Integer providerServiceMapID = request.getProviderServiceMapID(); + DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); + String orderEvent = request.getOrderEvent(); + String patientFirstName = request.getPatient().getFirstName(); + String patientLastName = request.getPatient().getLastName(); + String patientDateOfBirth = request.getPatient().getDateOfBirth(); + String patientSex = request.getPatient().getSex(); + + String providerCode = activeProvider; + String externalOrderId = String.format("%d-%d-%s", benRegID, visitCode, orderType.name()); + + Optional existing = + diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType.name()); + + if (existing.isPresent() && !DiagnosticOrderStatus.FAILED.name().equals(existing.get().getStatus())) { + // Idempotent: already pushed (or in flight / completed) for this visit+orderType — don't push again. + return existing.get(); + } + + DiagnosticOrder order = existing.orElseGet(DiagnosticOrder::new); + order.setOrderEvent(orderEvent); + order.setBenRegID(benRegID); + order.setVisitCode(visitCode); + order.setProviderServiceMapID(providerServiceMapID); + order.setProviderCode(providerCode); + order.setOrderType(orderType.name()); + order.setExternalOrderId(externalOrderId); + // Explicit reset required when reusing a previously-FAILED row: without this, a successful + // retry would leave status=FAILED (the push-success branch below only sets providerOrderId), + // and findDueForPoll only selects PENDING/IN_PROGRESS — so the order would be silently pushed + // to the real device queue but never polled again. + order.setStatus(DiagnosticOrderStatus.PENDING.name()); + order.setErrorMessage(null); + order.setPatientFirstName(patientFirstName); + order.setPatientLastName(patientLastName); + order.setPatientDateOfBirth(patientDateOfBirth); + order.setPatientSex(patientSex); +// order.setCreatedBy(createdBy); + try { + order = diagnosticOrderRepo.save(order); + } catch (DataIntegrityViolationException dive) { + // Lost a create race to a concurrent identical request — return the winner idempotently + // instead of surfacing a 5000 error for what is, from the caller's perspective, a success. + Optional winner = diagnosticOrderRepo + .findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType.name()); + if (winner.isPresent()) { + logger.warn("Lost create race for benRegID={}, visitCode={}, orderType={} — returning existing order id={}", + benRegID, visitCode, orderType, winner.get().getId()); + return winner.get(); + } + throw dive; // not our natural-key constraint — genuinely unexpected, don't swallow + } + + try { + DiagnosticProvider provider = providerFactory.getProvider(providerCode); + DiagnosticPushResult pushResult = provider.pushOrder(order); + order.setPushResponseJson(pushResult.getRawResponseJson()); + if (pushResult.isSuccess()) { + order.setProviderOrderId(pushResult.getProviderOrderId()); + } else { + order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setErrorMessage(pushResult.getErrorMessage()); + } + } catch (Exception e) { + logger.error("Failed to push order to provider, orderId={}: {}", order.getId(), e.getMessage()); + order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setErrorMessage(e.getMessage()); + } + order = diagnosticOrderRepo.save(order); + + return order; + } + + @Override + public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { + // One DiagnosticResult per DiagnosticOrder — each poll updates the same row in place + // rather than accumulating a new row per attempt, since most intermediate polls before + // completion carry nothing new and DiagnosticOrder.lastPolledAt/status already tracks + // poll progress over time. The unique constraint on diagnostic_order_id also means a + // concurrent poll (e.g. this manual trigger racing the scheduler) safely loses to a + // DataIntegrityViolationException instead of creating a duplicate row. + Optional existingResult = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()); + DiagnosticResult result = existingResult.orElseGet(DiagnosticResult::new); + result.setDiagnosticOrderId(order.getId()); + result.setBenRegID(order.getBenRegID()); + result.setProviderStatus(pollResult.getStatus().name()); + result.setResultSummary(pollResult.getResultSummary()); + result.setRawResponseJson(pollResult.getRawResponseJson()); + result.setTbPresence(pollResult.getTbPresence()); + result.setTbConfidence(pollResult.getTbConfidence()); + result.setCreatedBy("SYSTEM"); + try { + diagnosticResultRepo.save(result); + } catch (DataIntegrityViolationException dive) { + // Lost an upsert race to a concurrent poll for the same order — the winner already + // persisted an equivalent result; nothing further to do. + logger.warn("Lost result upsert race for diagnosticOrderId={}", order.getId()); + result = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()).orElse(result); + } + + // Assets can arrive before the order is fully complete (e.g. the X-ray's PRIMARY_CAPTURE + // while CAD is still pending), so ingestion runs on every poll, not gated on status. + // ingestAsset() itself de-dupes re-sent assets, so this is safe to call repeatedly. + if (pollResult.getAssets() != null) { + for (DiagnosticDocumentAsset asset : pollResult.getAssets()) { + try { + diagnosticDocumentService.ingestAsset(order.getId(), order.getBenRegID(), order.getOrderType(), + order.getExternalOrderId(), asset); + } catch (Exception e) { + logger.error("Failed to ingest document asset for orderId={}, assetType={}, fileName={}: {}", + order.getId(), asset.getType(), asset.getFileName(), e.getMessage()); + } + } + } + + order.setStatus(pollResult.getStatus().name()); + order.setErrorMessage(pollResult.getErrorMessage()); + if (pollResult.getProviderOrderId() != null) { + order.setProviderOrderId(pollResult.getProviderOrderId()); + } + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + diagnosticOrderRepo.save(order); + + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setDiagnosticOrderId(order.getId()); + dto.setOrderType(order.getOrderType()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + dto.setProviderStatus(result.getProviderStatus()); + dto.setResultSummary(result.getResultSummary()); + dto.setTbPresence(result.getTbPresence()); + dto.setTbConfidence(result.getTbConfidence()); + return dto; + } + + @Override + public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { + DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); + DiagnosticPollResult result = provider.pollResult(order, false); + if (DiagnosticOrderStatus.COMPLETED.equals(result.getStatus())) { + // Order just resolved COMPLETED on a status-only check — make the one follow-up call + // that actually fetches the report/image content. + result = provider.pollResult(order, true); + } + return result; + } + + private DiagnosticOrder findLatestOrder(Long benRegID, String orderType) throws Exception { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + return diagnosticOrderRepo + .findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, type.name()) + .orElseThrow(() -> new Exception( + "DiagnosticOrder not found for benRegID=" + benRegID + ", orderType=" + orderType)); + } + + @Override + public DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType) throws Exception { + DiagnosticOrder order = findLatestOrder(benRegID, orderType); + DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); + // Ops-triggered manual poll: always fetch whatever is currently available, skip the + // status-then-assets optimization used by the scheduler. + DiagnosticPollResult pollResult = provider.pollResult(order, true); + return processResult(order, pollResult); + } + + @Override + public DiagnosticOrder markTestCompleted(Long benRegID, String orderType) throws Exception { + DiagnosticOrder order = findLatestOrder(benRegID, orderType); + if (order.getTestCompletedAt() != null) { + return order; // idempotent — already flagged, don't reset the poll clock + } + String status = order.getStatus(); + if (DiagnosticOrderStatus.COMPLETED.name().equals(status) + || DiagnosticOrderStatus.FAILED.name().equals(status) + || DiagnosticOrderStatus.CANCELLED.name().equals(status)) { + throw new IllegalStateException("Cannot mark test completed for order in terminal status " + status); + } + order.setTestCompletedAt(new Timestamp(System.currentTimeMillis())); + order.setStatus(DiagnosticOrderStatus.IN_PROGRESS.name()); + return diagnosticOrderRepo.save(order); + } + + @Override + public DiagnosticOrder getOrder(Long benRegID, String orderType) throws Exception { + return findLatestOrder(benRegID, orderType); + } + + @Override + public List getOrdersByBenRegId(Long benRegID) throws Exception { + return diagnosticOrderRepo.findByBenRegID(benRegID); + } + + @Override + public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setOrderType(orderType); + + Optional orderOpt = + diagnosticOrderRepo.findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, orderType); + if (orderOpt.isEmpty()) { + dto.setStatus("NOT_FOUND"); + return dto; + } + + DiagnosticOrder order = orderOpt.get(); + dto.setDiagnosticOrderId(order.getId()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + + diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()).ifPresent(result -> { + dto.setProviderStatus(result.getProviderStatus()); + dto.setResultSummary(result.getResultSummary()); + dto.setTbPresence(result.getTbPresence()); + dto.setTbConfidence(result.getTbConfidence()); + }); + return dto; + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java new file mode 100644 index 00000000..e9026c6c --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -0,0 +1,117 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.service.DiagnosticOrderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Service +public class DiagnosticPollSchedulerService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticPollSchedulerService.class); + + @Value("${diagnostic.poll.give-up-minutes}") + private int giveUpMinutes; + + @Value("${diagnostic.poll.interval-seconds}") + private int pollIntervalSeconds; + + @Value("${diagnostic.poll.truenat.initial-delay-minutes}") + private int truenatInitialDelayMinutes; + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private DiagnosticOrderService diagnosticOrderService; + + @Scheduled(fixedDelayString = "${diagnostic.poll.tick-ms:15000}") + public void pollPendingOrders() { + List candidates = diagnosticOrderRepo.findDueForPoll(); + logger.info("Diagnostic poll tick: {} order(s) in due-for-poll queue", candidates.size()); + if (candidates.isEmpty()) return; + + Instant now = Instant.now(); + int polled = 0; + for (DiagnosticOrder order : candidates) { + if (isExpired(order, now)) { + giveUp(order); + } else if (isDue(order, now)) { + pollSingle(order); + polled++; + } + } + if (polled > 0) { + logger.info("Polled {} pending diagnostic orders", polled); + } + } + + // XRAY_CHEST: polls from the moment the test is marked completed. TrueNat: waits + // truenatInitialDelayMinutes after that (the machine's own processing time) before polling + // starts. Both then poll at the same pollIntervalSeconds cadence. + private boolean isDue(DiagnosticOrder order, Instant now) { + Instant pollingStartedAt = pollingStartedAt(order); + if (pollingStartedAt.isAfter(now)) { + return false; // still inside TrueNat's initial delay window + } + Timestamp lastPolledAt = order.getLastPolledAt(); + if (lastPolledAt == null) { + return true; + } + return !lastPolledAt.toInstant().plusSeconds(pollIntervalSeconds).isAfter(now); + } + + // Give-up window is measured from when polling actually starts for this order (immediately + // for X-ray, after the initial delay for TrueNat) - not from testCompletedAt directly for + // TrueNat - so a shared give-up-minutes value is meaningful for both types instead of + // auto-failing TrueNat orders before they're ever polled. + private boolean isExpired(DiagnosticOrder order, Instant now) { + Instant deadline = pollingStartedAt(order).plus(giveUpMinutes, ChronoUnit.MINUTES); + return !deadline.isAfter(now); + } + + private Instant pollingStartedAt(DiagnosticOrder order) { + Instant testCompletedAt = order.getTestCompletedAt().toInstant(); + DiagnosticOrderType type = DiagnosticOrderType.fromCode(order.getOrderType()); + if (type == DiagnosticOrderType.XRAY_CHEST) { + return testCompletedAt; + } + return testCompletedAt.plus(truenatInitialDelayMinutes, ChronoUnit.MINUTES); + } + + private void giveUp(DiagnosticOrder order) { + order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setErrorMessage("Polling window exceeded (" + giveUpMinutes + " min) without a result"); + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + diagnosticOrderRepo.save(order); + logger.warn("Order {} exceeded give-up window of {} min, marked FAILED", order.getId(), giveUpMinutes); + } + + private void pollSingle(DiagnosticOrder order) { + try { + DiagnosticPollResult result = diagnosticOrderService.pollOnce(order); + diagnosticOrderService.processResult(order, result); + } catch (Exception e) { + logger.error("Poll failed for orderId={}: {}", order.getId(), e.getMessage()); + // Purely an observability counter - does not gate future polling or mark the order + // FAILED; the give-up window above is what eventually closes out a stuck order. + order.setRetryCount(order.getRetryCount() + 1); + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setErrorMessage(e.getMessage()); + diagnosticOrderRepo.save(order); + } + } +} From 078747a24802bf0e77ad8a902de4fa1a737d5ac0 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 17 Jul 2026 10:31:55 +0530 Subject: [PATCH 36/47] feat(stoptb): return visitCode in save responses for ABDM M2 flow Co-Authored-By: Claude Sonnet 5 --- src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index f73eac4e..f832136e 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -380,6 +380,7 @@ public List> saveGeneralExamination(List Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", visit.getVisitCode()); result.put("referralToHWCNeeded", exam.getReferralToHWCNeeded()); results.add(result); } @@ -518,6 +519,7 @@ public List> saveNurseTBScreening(List> Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", visit.getVisitCode()); results.add(result); } return results; @@ -628,6 +630,7 @@ public List> saveGeneralOpd(List> dataLi Map result = new HashMap<>(); result.put("beneficiaryRegID", beneficiaryRegID); + result.put("visitCode", visit.getVisitCode()); results.add(result); } return results; From b74654df0f9818821b394ea4c272252fbb771df2 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 17 Jul 2026 11:43:44 +0530 Subject: [PATCH 37/47] feat(stoptb): return visitCode in general exam/screening/OPD get responses Co-Authored-By: Claude Sonnet 5 --- src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index f832136e..8fe1e273 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -419,6 +419,7 @@ private Map examToMap(StopTBGeneralExamination e) { Map m = new LinkedHashMap<>(); m.put("id", e.getId()); m.put("beneficiaryRegID", e.getBeneficiaryRegID()); + m.put("visitCode", e.getVisitCode()); m.put("providerServiceMapID", e.getProviderServiceMapID()); m.put("pulseRate", e.getPulseRate()); m.put("systolicBP", e.getSystolicBP()); @@ -549,6 +550,7 @@ private Map screeningToMap(TBScreening s) { Map m = new LinkedHashMap<>(); m.put("id", s.getId()); m.put("beneficiaryRegID", s.getBenRegID()); + m.put("visitCode", s.getVisitCode()); m.put("providerServiceMapID", s.getProviderServiceMapID()); m.put("coughMoreThan2Weeks", s.getCoughMoreThan2Weeks()); m.put("bloodInSputum", s.getBloodInSputum()); @@ -660,6 +662,7 @@ private Map opdToMap(StopTBGeneralOpd o) { Map m = new LinkedHashMap<>(); m.put("id", o.getId()); m.put("beneficiaryRegID", o.getBenRegID()); + m.put("visitCode", o.getVisitCode()); m.put("providerServiceMapID", o.getProviderServiceMapID()); m.put("chiefComplaint", o.getChiefComplaint()); m.put("medication", o.getMedication()); From 20ed0635888aa199d228b6bb564f5f3d4692106c Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 17 Jul 2026 17:03:57 +0530 Subject: [PATCH 38/47] =?UTF-8?q?=E2=97=8F=20Add=20TrueNat=20TB/drug-resis?= =?UTF-8?q?tance=20classification,=20split=20XRAY/TrueNat=20pollers,=20fix?= =?UTF-8?q?=20document-type=20collisions,=20and=20harden=20EMRLITE=20token?= =?UTF-8?q?/error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/environment/common_ci.properties | 3 +- src/main/environment/common_docker.properties | 3 +- .../environment/common_example.properties | 14 +- src/main/environment/common_local.properties | 3 +- .../DiagnosticDocumentController.java | 6 +- .../controller/DiagnosticOrderController.java | 5 +- .../flw/domain/iemr/DiagnosticDocument.java | 12 +- .../iemr/flw/domain/iemr/DiagnosticOrder.java | 4 +- .../flw/domain/iemr/DiagnosticResult.java | 3 + .../flw/dto/DiagnosticOrderRequestDto.java | 7 +- .../flw/dto/DiagnosticOrderResultDto.java | 3 +- .../provider/DiagnosticPollResult.java | 1 + .../provider/emrlite/EmrLiteProvider.java | 121 +++++++++--------- .../provider/emrlite/EmrLiteTokenManager.java | 14 +- .../emrlite/dto/EmrLiteLoginResponse.java | 2 + .../emrlite/dto/EmrLiteProviderResponse.java | 9 +- .../masterEnum/DiagnosticDocumentType.java | 38 ++++-- .../flw/repo/iemr/DiagnosticOrderRepo.java | 11 +- .../impl/DiagnosticDocumentServiceImpl.java | 23 +--- .../impl/DiagnosticOrderServiceImpl.java | 66 +++++----- .../impl/DiagnosticPollSchedulerService.java | 76 ++++++----- 21 files changed, 212 insertions(+), 212 deletions(-) diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 33462845..191f5401 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -55,9 +55,10 @@ diagnostic.emrlite.password=@env.DIAGNOSTIC_EMRLITE_PASSWORD@ diagnostic.emrlite.token-ttl-seconds=@env.DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS@ diagnostic.emrlite.refresh-ttl-seconds=@env.DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS@ -diagnostic.poll.tick-ms=@env.DIAGNOSTIC_POLL_TICK_MS@ +diagnostic.poll.xray.tick-ms=@env.DIAGNOSTIC_POLL_XRAY_TICK_MS@ diagnostic.poll.give-up-minutes=@env.DIAGNOSTIC_POLL_GIVE_UP_MINUTES@ diagnostic.poll.interval-seconds=@env.DIAGNOSTIC_POLL_INTERVAL_SECONDS@ diagnostic.poll.truenat.initial-delay-minutes=@env.DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES@ +diagnostic.poll.truenat.tick-ms=@env.DIAGNOSTIC_POLL_TRUENAT_TICK_MS@ diagnostic.documents.storage-root=@env.DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index e42a437e..2cb36687 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -54,10 +54,11 @@ diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} -diagnostic.poll.tick-ms=${DIAGNOSTIC_POLL_TICK_MS} +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 8783b16e..7250eced 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -1,21 +1,20 @@ server.port=8081 +server.servlet.context-path=/flw-api # FHIR Config fhir-url=http://localhost:8093/ - # TM Config -tm-url=http://localhost:8089/ ##--------------------------------------------## Primary db------------------------------------------------------------------- spring.datasource.url=jdbc:mysql://localhost:3306/db_iemr spring.datasource.username=root -spring.datasource.password=1234 +spring.datasource.password=Hrhk@4321 spring.datasource.driver-class-name=com.mysql.jdbc.Driver ##--------------------------------------------## Secondary db------------------------------------------------------------------- secondary.datasource.url=jdbc:mysql://localhost:3306/db_identity secondary.datasource.username=root -secondary.datasource.password=1234 +secondary.datasource.password=Hrhk@4321 secondary.datasource.driver-class-name=com.mysql.jdbc.Driver springdoc.api-docs.enabled=true @@ -24,10 +23,8 @@ springdoc.swagger-ui.enabled=true logging.path=logs/ logging.file.name=logs/flw-api.log - jwt.secret=eefa40497bb9b2a8491b85069aff62e7596521c6816e471c464181b52eac7670ff91394a - # Redis Config spring.redis.host=localhost @@ -58,11 +55,10 @@ diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} -diagnostic.poll.tick-ms=${DIAGNOSTIC_POLL_TICK_MS} +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} - - diff --git a/src/main/environment/common_local.properties b/src/main/environment/common_local.properties index e2f8066c..f6b4586d 100644 --- a/src/main/environment/common_local.properties +++ b/src/main/environment/common_local.properties @@ -87,10 +87,11 @@ diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} -diagnostic.poll.tick-ms=${DIAGNOSTIC_POLL_TICK_MS} +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java index 791040c2..b96442c1 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java @@ -31,11 +31,9 @@ public DiagnosticDocumentController(DiagnosticDocumentService diagnosticDocument this.diagnosticDocumentService = diagnosticDocumentService; } - // Returns raw bytes with a real Content-Type header instead of this app's usual - // OutputResponse-wrapped-JSON-string, since the payload here is genuinely binary - // (a PDF/image), not JSON data. @GetMapping - @Operation(summary = "Fetch the most recent stored document for a beneficiary + document type (XRAY_CHEST or CAD)") + @Operation(summary = "Fetch the most recent stored document for a beneficiary + document type " + + "(XRAY_CHEST, XRAY_CHEST_ANNOTATED, CAD, MTB_REPORT, MTB_PLUS_REPORT, or MDR_RIF_REPORT)") public ResponseEntity getDocument(@RequestParam Long benId, @RequestParam DiagnosticDocumentType documentType) { try { DiagnosticDocumentContent content = diagnosticDocumentService.fetch(benId, documentType); diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java index 3cd509dd..bcc50d5c 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -1,6 +1,7 @@ package com.iemr.flw.controller; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; @@ -88,7 +89,7 @@ public String getOrderResult(@RequestParam Long benId, @RequestParam String orde OutputResponse response = new OutputResponse(); try { DiagnosticOrderResultDto result = diagnosticOrderService.getOrderResult(benId, orderType); - response.setResponse(new Gson().toJson(result)); + response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in getOrderResult: {}", e.getMessage()); response.setError(5000, "Error fetching diagnostic result: " + e.getMessage()); @@ -102,7 +103,7 @@ public String pollOrder(@RequestParam Long benRegID, @RequestParam String orderT OutputResponse response = new OutputResponse(); try { DiagnosticOrderResultDto result = diagnosticOrderService.triggerManualPoll(benRegID, orderType); - response.setResponse(new Gson().toJson(result)); + response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in pollOrder: {}", e.getMessage()); response.setError(5000, "Error triggering poll: " + e.getMessage()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java index 500e0b36..c24e9422 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java @@ -9,9 +9,6 @@ @Entity @Table(name = "tb_diagnostic_document", schema = "db_iemr", - // One row per (beneficiary, documentType) — always the latest of that kind for this - // beneficiary, regardless of which DiagnosticOrder produced it (mirrors the same - // "current state, not history" approach used for DiagnosticResult). uniqueConstraints = @UniqueConstraint(columnNames = {"ben_reg_id", "document_type"}), indexes = { @Index(name = "idx_diagnostic_document_ben_reg_id", columnList = "ben_reg_id"), @@ -25,26 +22,19 @@ public class DiagnosticDocument { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - // Informational only — which order most recently (re-)produced this document. Not part of - // the uniqueness key since documents are tracked per beneficiary+type, not per order. @Column(name = "diagnostic_order_id") private Long diagnosticOrderId; @Column(name = "ben_reg_id") private Long benRegID; - // The parent DiagnosticOrder's own orderType (e.g. XRAY_CHEST) — always accurate, unrelated - // to documentType below. @Column(name = "order_type", length = 20) private String orderType; - // Raw provider asset type (e.g. PRIMARY_CAPTURE, REPORT) — kept for audit/debugging. @Column(name = "asset_type", length = 50) private String assetType; - // DiagnosticDocumentType classification (XRAY_CHEST vs CAD), derived from assetType at - // ingest time. This, not orderType/assetType, is the key used for storage and fetch. - @Column(name = "document_type", length = 20) + @Column(name = "document_type", length = 30) private String documentType; @Column(name = "epoch_time") diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java index 3d5a88fc..5b68328e 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -23,8 +23,8 @@ public class DiagnosticOrder { @Column(name = "ben_reg_id") private Long benRegID; - @Column(name = "provider_service_map_id") - private Integer providerServiceMapID; + @Column(name = "provider_service_name", length = 50) + private String providerServiceName; @Column(name = "provider_code", length = 50) private String providerCode; diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java index a7f9fe3c..ce292753 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java @@ -38,6 +38,9 @@ public class DiagnosticResult { @Column(name = "tb_confidence") private Double tbConfidence; + @Column(name = "drug_resistance_presence") + private Boolean drugResistancePresence; + @Column(name = "created_by") private String createdBy; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java index 7c68c3ef..660ad0d5 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -17,9 +17,6 @@ public class DiagnosticOrderRequestDto { @NotNull private Long visitCode; - @NotNull - private Integer providerServiceMapID; - @NotNull private String orderType; @@ -37,6 +34,8 @@ public static class PatientDto { @NotBlank @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "dateOfBirth must be in yyyy-MM-dd format") private String dateOfBirth; - @NotBlank private String sex; + @NotBlank + @Pattern(regexp = "Male|Female|Others", message = "sex must be exactly one of Male, Female, Others") + private String sex; } } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java index 04f4ead9..25b3d658 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -7,7 +7,7 @@ @NoArgsConstructor public class DiagnosticOrderResultDto { - private Long diagnosticOrderId; + private String externalOrderId; private String orderType; private String status; private String providerStatus; @@ -15,4 +15,5 @@ public class DiagnosticOrderResultDto { private String errorMessage; private Boolean tbPresence; private Double tbConfidence; + private Boolean drugResistancePresence; } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java index 97de682f..9f652bf6 100644 --- a/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java @@ -19,4 +19,5 @@ public class DiagnosticPollResult { private String errorMessage; private Boolean tbPresence; private Double tbConfidence; + private Boolean drugResistancePresence; } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java index 1c266f37..2dc65eea 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -1,6 +1,8 @@ package com.iemr.flw.integration.provider.emrlite; import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; @@ -8,6 +10,7 @@ import com.iemr.flw.integration.provider.DiagnosticPushResult; import com.iemr.flw.integration.provider.emrlite.dto.*; import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.masterEnum.DiagnosticOrderType; import com.iemr.flw.masterEnum.DiagnosticProviderCode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,22 +38,15 @@ public class EmrLiteProvider implements DiagnosticProvider { private static final String CAD_COMPONENT_KEY = "cad"; private static final String TUBERCULOSIS_FINDING_NAME = "Tuberculosis"; + private static final String SUMMARY_TB_POSITIVE = "TB Positive"; + private static final String SUMMARY_DR_TB = "DR TB"; + @Value("${diagnostic.emrlite.order-url}") private String orderUrl; @Value("${diagnostic.emrlite.result-url}") private String resultUrl; - // No longer used directly here — EmrLiteAuthInterceptor (wired into the emrLiteRestTemplate - // bean via EmrLiteRestTemplateConfig) now owns calling tokenManager.getValidToken()/forceRefresh(). - // Kept commented for reference/rollback alongside postWithAuth(...) below. - // @Autowired - // private EmrLiteTokenManager tokenManager; - - // Superseded by the interceptor-backed emrLiteRestTemplate bean below (EmrLiteAuthInterceptor - // now handles attaching/refreshing the bearer token for every call on this RestTemplate). - // private final RestTemplate restTemplate = new RestTemplate(); - @Autowired @Qualifier("emrLiteRestTemplate") private RestTemplate restTemplate; @@ -67,23 +63,24 @@ public DiagnosticPushResult pushOrder(DiagnosticOrder order) throws Exception { EmrLiteOrderRequest request = buildOrderRequest(order); String responseBody; try { - // responseBody = postWithAuth(orderUrl, gson.toJson(request)); // superseded by EmrLiteAuthInterceptor responseBody = doPost(orderUrl, gson.toJson(request)); } catch (HttpStatusCodeException e) { String body = e.getResponseBodyAsString(); String providerMessage = null; + String errorDetail = ""; try { EmrLiteProviderResponse errorEnvelope = gson.fromJson(body, EmrLiteProviderResponse.class); if (errorEnvelope != null) { providerMessage = errorEnvelope.getMessage(); + errorDetail = describeErrorData(errorEnvelope.getData()); } } catch (Exception parseEx) { logger.debug("Could not parse provider error body as envelope JSON for externalOrderId={}", order.getExternalOrderId()); } String errorMessage = (providerMessage != null && !providerMessage.isBlank()) - ? "HTTP " + e.getStatusCode() + ": " + providerMessage - : "HTTP " + e.getStatusCode(); + ? "HTTP " + e.getStatusCode() + ": " + providerMessage + errorDetail + : "HTTP " + e.getStatusCode() + errorDetail; logger.warn("Provider HTTP error on order push: externalOrderId={}, status={}, message={}", order.getExternalOrderId(), e.getStatusCode(), providerMessage); return new DiagnosticPushResult(false, null, body, errorMessage); @@ -99,30 +96,24 @@ public DiagnosticPushResult pushOrder(DiagnosticOrder order) throws Exception { EmrLiteOrderResponse orderResponse = gson.fromJson(envelope.getData(), EmrLiteOrderResponse.class); logger.info("Order pushed: externalOrderId={}, status={}", order.getExternalOrderId(), orderResponse.getStatus()); - // Provider echoes our externalOrderId back; store it as providerOrderId for audit return new DiagnosticPushResult(true, orderResponse.getExternalOrderId(), responseBody, null); } @Override public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception { - // Always poll using our own externalOrderId per the API contract EmrLiteResultRequest request = new EmrLiteResultRequest(order.getExternalOrderId(), includeAssets); String responseBody; try { - // responseBody = postWithAuth(resultUrl, gson.toJson(request)); // superseded by EmrLiteAuthInterceptor responseBody = doPost(resultUrl, gson.toJson(request)); } catch (HttpClientErrorException.NotFound e) { - // Permanent per the API contract ("does not exist or belongs to a different integration - // client") — return a normal FAILED result instead of throwing, so the poll scheduler - // stops immediately instead of retrying this on the transient exponential back-off schedule. + // Permanent per the API contract — return FAILED instead of throwing, so the scheduler + // stops instead of retrying on the transient back-off schedule. logger.warn("Provider returned 404 Not Found for externalOrderId={}", order.getExternalOrderId()); return new DiagnosticPollResult(DiagnosticOrderStatus.FAILED, null, null, e.getResponseBodyAsString(), null, "Order not found on provider (404): externalOrderId=" + order.getExternalOrderId(), - null, null); + false, null, false); } - // Other HttpStatusCodeException subtypes (401 surviving the auth-interceptor retry, 5xx, etc.) - // are not caught here and propagate to the scheduler's existing transient-retry-with-backoff path. EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); if (!envelope.isSuccess()) { @@ -137,15 +128,31 @@ public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAss EmrLiteCadRawJson.FindingDto tbFinding = extractTuberculosisFinding(result); Boolean tbPresence = tbFinding != null ? tbFinding.isPresence() : null; Double tbConfidence = tbFinding != null ? tbFinding.getConfidence() : null; + if (tbPresence == null) { + tbPresence = classifyTbPresenceFromSummary(order.getOrderType(), summary); + } + Boolean drugResistancePresence = classifyDrugResistanceFromSummary(order.getOrderType(), summary); return new DiagnosticPollResult(status, order.getExternalOrderId(), summary, responseBody, assets, null, - tbPresence, tbConfidence); + tbPresence, tbConfidence, drugResistancePresence); + } + + private Boolean classifyTbPresenceFromSummary(String orderType, String summary) { + if (!DiagnosticOrderType.MTB.name().equals(orderType) && !DiagnosticOrderType.MTB_PLUS.name().equals(orderType)) { + return false; + } + return SUMMARY_TB_POSITIVE.equalsIgnoreCase(summary); + } + + // MDR_RIF's summary reports rifampicin drug-resistance, not raw TB presence — kept as its own + // field rather than folded into tbPresence. + private Boolean classifyDrugResistanceFromSummary(String orderType, String summary) { + if (!DiagnosticOrderType.MDR_RIF.name().equals(orderType)) { + return false; + } + return SUMMARY_DR_TB.equalsIgnoreCase(summary); } - // The CAD (e.g. DRONGOAI) findings list lives inside rawJson.results.findings, which only - // exists once a "cad" component has run. Deliberately does not touch rawJson.results.image - - // that's an internal provider media path, not a URL or base64 payload we can use; the actual - // X-ray capture (when sent) arrives separately via result.assets, handled by mapAssets() above. private EmrLiteCadRawJson.FindingDto extractTuberculosisFinding(EmrLiteResultResponse result) { if (result.getComponents() == null || !result.getComponents().containsKey(CAD_COMPONENT_KEY)) { return null; @@ -170,12 +177,8 @@ private EmrLiteCadRawJson.FindingDto extractTuberculosisFinding(EmrLiteResultRes } } - // The provider's top-level "status" is not reliable on its own: a real example response - // ("XRAY Completed with Assets, CAD Pending") shows status=COMPLETED while the cad component - // is still PENDING and the summary literally says "CAD Pending". Derive true completion from - // the components map instead - COMPLETED only when every component is COMPLETED, FAILED if any - // component is FAILED - falling back to the top-level status only for the PENDING/IN_PROGRESS - // distinction (or when the provider sends no components at all). + // Provider's top-level "status" is unreliable on its own (can report COMPLETED while a + // component is still PENDING), so true completion is derived from the components map instead. private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { Map components = result.getComponents(); if (components == null || components.isEmpty()) { @@ -193,16 +196,36 @@ private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { } DiagnosticOrderStatus topLevelStatus = DiagnosticOrderStatus.fromString(result.getStatus()); if (topLevelStatus == DiagnosticOrderStatus.COMPLETED || topLevelStatus == DiagnosticOrderStatus.FAILED) { - // Components contradict a terminal top-level status (e.g. xray COMPLETED, cad PENDING, - // top-level status still says COMPLETED) — only the components map may resolve to a - // terminal state; treat this as still in progress. + // Components contradict a terminal top-level status — only the components map may + // resolve to a terminal state; treat this as still in progress. return DiagnosticOrderStatus.IN_PROGRESS; } return topLevelStatus; } - // Assets can be present before the order is fully complete (e.g. the X-ray's PRIMARY_CAPTURE - // is returned while CAD is still PENDING), so this is extracted independently of order status. + private String describeErrorData(JsonElement data) { + if (data == null || !data.isJsonObject()) { + return ""; + } + JsonObject dataObject = data.getAsJsonObject(); + JsonElement code = dataObject.get("code"); + JsonElement field = dataObject.get("field"); + if (code == null && field == null) { + return ""; + } + StringBuilder detail = new StringBuilder(" ("); + if (code != null) { + detail.append("code=").append(code.getAsString()); + } + if (field != null) { + if (code != null) { + detail.append(", "); + } + detail.append("field=").append(field.getAsString()); + } + return detail.append(")").toString(); + } + private List mapAssets(EmrLiteResultResponse result) { if (result.getResult() == null || result.getResult().getAssets() == null) { return new ArrayList<>(); @@ -215,28 +238,6 @@ private List mapAssets(EmrLiteResultResponse result) { return assets; } - // Superseded by EmrLiteAuthInterceptor, which now attaches/refreshes the bearer token - // for every call made on the emrLiteRestTemplate bean. Kept for reference/rollback. - // private String postWithAuth(String url, String jsonBody) throws Exception { - // String token = tokenManager.getValidToken(); - // try { - // return doPost(url, jsonBody, token); - // } catch (HttpClientErrorException.Unauthorized e) { - // logger.warn("Received 401 from provider, forcing token refresh and retrying"); - // token = tokenManager.forceRefresh(); - // return doPost(url, jsonBody, token); - // } - // } - // - // private String doPost(String url, String jsonBody, String token) { - // HttpHeaders headers = new HttpHeaders(); - // headers.setContentType(MediaType.APPLICATION_JSON); - // headers.setBearerAuth(token); - // HttpEntity entity = new HttpEntity<>(jsonBody, headers); - // ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); - // return response.getBody(); - // } - private String doPost(String url, String jsonBody) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java index 08d9518f..2b0b3dd6 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java @@ -47,10 +47,10 @@ public class EmrLiteTokenManager { @Value("${diagnostic.emrlite.password}") private String encodedPassword; - @Value("${diagnostic.emrlite.token-ttl-seconds}") + @Value("${diagnostic.emrlite.token-ttl-seconds:3600}") private long tokenTtlSeconds; - @Value("${diagnostic.emrlite.refresh-ttl-seconds}") + @Value("${diagnostic.emrlite.refresh-ttl-seconds:82800}") private long refreshTtlSeconds; private String password; @@ -82,7 +82,6 @@ public String getValidToken() throws Exception { return cryptoUtil.decrypt(token.getTokenValue()); } } - // No token in DB or token is expired — acquire lock to prevent parallel logins/refreshes synchronized (this) { Optional recheck = tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_ACCESS); @@ -118,9 +117,6 @@ private String refreshAccessToken() throws Exception { throw new Exception("No refresh token in DB — need full login"); } DiagnosticProviderToken refreshToken = refreshRecord.get(); - // Same expiry-with-safety-buffer check already applied to the access token in - // getValidToken() — no point sending the provider a refresh token we already know has - // expired; fail fast so refreshOrLogin() falls back to a full login immediately. boolean refreshExpired = refreshToken.getExpiresAt() == null || Instant.now().isAfter(refreshToken.getExpiresAt().toInstant().minusSeconds(60)); if (refreshExpired) { @@ -218,14 +214,16 @@ private String login() throws Exception { throw new Exception("Login returned null accessToken"); } + long accessTtlSeconds = tokens.getExpiresIn() != null ? tokens.getExpiresIn() : tokenTtlSeconds; persistToken(TYPE_ACCESS, tokens.getAccessToken(), - Timestamp.from(Instant.now().plus(tokenTtlSeconds, ChronoUnit.SECONDS))); + Timestamp.from(Instant.now().plus(accessTtlSeconds, ChronoUnit.SECONDS))); if (tokens.getRefreshToken() != null) { persistToken(TYPE_REFRESH, tokens.getRefreshToken(), Timestamp.from(Instant.now().plus(refreshTtlSeconds, ChronoUnit.SECONDS))); } - logger.info("EMR Lite login successful, access TTL {}s, refresh TTL {}s", tokenTtlSeconds, refreshTtlSeconds); + logger.info("EMR Lite login successful, access TTL {}s (from {}), refresh TTL {}s", accessTtlSeconds, + tokens.getExpiresIn() != null ? "provider" : "config", refreshTtlSeconds); return tokens.getAccessToken(); } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java index 24659e11..c36e519b 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java @@ -8,4 +8,6 @@ public class EmrLiteLoginResponse { private String accessToken; private String refreshToken; + private String tokenType; + private Integer expiresIn; } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java index 6af51112..0c34267f 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java @@ -1,17 +1,16 @@ package com.iemr.flw.integration.provider.emrlite.dto; -import com.google.gson.JsonObject; +import com.google.gson.JsonElement; import lombok.Data; import lombok.NoArgsConstructor; -/** - * Generic envelope returned by every provider endpoint: {"Result":"Success|Failure","Data":{...},"Message":"..."} - */ +// Data is JsonElement, not JsonObject, since the API sometimes returns it as a plain string +// (e.g. error cases) instead of an object. @Data @NoArgsConstructor public class EmrLiteProviderResponse { private String Result; - private JsonObject Data; + private JsonElement Data; private String Message; public boolean isSuccess() { diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java index d0da78ca..5416df34 100644 --- a/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java @@ -1,18 +1,34 @@ package com.iemr.flw.masterEnum; -/** - * Classifies a stored DiagnosticDocument independently of the parent DiagnosticOrder's own - * orderType (which stays e.g. XRAY_CHEST regardless of which artifact this is). Derived - * automatically from the provider's asset.type at ingest time - callers never pass a raw - * "CAD" string, they just ask for this enum. - */ +// XRAY_CHEST orders can carry up to three distinct artifacts (raw capture, AI-annotated capture, +// CAD report), each needing its own value under the (benRegID, documentType) storage key. public enum DiagnosticDocumentType { XRAY_CHEST, - CAD; + XRAY_CHEST_ANNOTATED, + CAD, + MTB_REPORT, + MTB_PLUS_REPORT, + MDR_RIF_REPORT; - private static final String CAD_REPORT_ASSET_TYPE = "REPORT"; + private static final String REPORT_ASSET_TYPE = "REPORT"; + private static final String SECONDARY_CAPTURE_ASSET_TYPE = "SECONDARY_CAPTURE"; - public static DiagnosticDocumentType fromAssetType(String assetType) { - return CAD_REPORT_ASSET_TYPE.equalsIgnoreCase(assetType) ? CAD : XRAY_CHEST; + public static DiagnosticDocumentType from(String orderType, String assetType) { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + if (type == DiagnosticOrderType.XRAY_CHEST) { + if (REPORT_ASSET_TYPE.equalsIgnoreCase(assetType)) { + return CAD; + } + if (SECONDARY_CAPTURE_ASSET_TYPE.equalsIgnoreCase(assetType)) { + return XRAY_CHEST_ANNOTATED; + } + return XRAY_CHEST; + } + switch (type) { + case MTB: return MTB_REPORT; + case MTB_PLUS: return MTB_PLUS_REPORT; + case MDR_RIF: return MDR_RIF_REPORT; + default: throw new IllegalArgumentException("Unknown orderType for document classification: " + orderType); + } } -} \ No newline at end of file +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index 260cebe2..7a892877 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -28,10 +28,17 @@ Optional findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByC @Query("SELECT o FROM DiagnosticOrder o WHERE o.benRegID = :benRegID AND o.deleted = false ORDER BY o.createdDate DESC") List findByBenRegID(@Param("benRegID") Long benRegID); - @Query("SELECT o FROM DiagnosticOrder o WHERE o.status IN ('PENDING', 'IN_PROGRESS') " + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType = 'XRAY_CHEST' " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + "AND o.deleted = false AND o.testCompletedAt IS NOT NULL " + "ORDER BY o.lastPolledAt ASC NULLS FIRST") - List findDueForPoll(); + List findXrayDueForPoll(); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType IN ('MTB', 'MTB_PLUS', 'MDR_RIF') " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + + "AND o.deleted = false AND o.testCompletedAt IS NOT NULL " + + "ORDER BY o.lastPolledAt ASC NULLS FIRST") + List findTrueNatDueForPoll(); @Transactional @Modifying diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java index 18efdaa4..665d426b 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -30,9 +30,6 @@ public class DiagnosticDocumentServiceImpl implements DiagnosticDocumentService private static final Logger logger = LoggerFactory.getLogger(DiagnosticDocumentServiceImpl.class); private static final String DEFAULT_CONTENT_TYPE = "application/pdf"; - // App-level encryption here protects against someone reading files directly off a - // live/mounted filesystem. It does NOT replace OS-level full-disk encryption (e.g. LUKS), - // which is what protects against physical theft of the drive - both should be in place. @Value("${diagnostic.documents.storage-root}") private String storageRoot; @@ -54,14 +51,7 @@ public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, byte[] originalBytes = Base64.getDecoder().decode(asset.getBase64Content()); String sha256Hash = sha256Hex(originalBytes); - // Classified automatically from the provider's asset.type - callers never pass "CAD" - // themselves. One DiagnosticDocument per (beneficiary, documentType) - always the latest - // of that kind, regardless of which order produced it, mirroring the same "current state, - // not history" approach used for DiagnosticResult. The provider resends the full current - // asset list on every poll (e.g. the X-ray's PRIMARY_CAPTURE is included again once CAD - // finishes, even though it's the same image), so skip re-writing when unchanged; a - // differing hash overwrites that type's file with the newer content. - DiagnosticDocumentType documentType = DiagnosticDocumentType.fromAssetType(asset.getType()); + DiagnosticDocumentType documentType = DiagnosticDocumentType.from(orderType, asset.getType()); Optional existing = diagnosticDocumentRepo.findByBenRegIDAndDocumentTypeAndDeletedFalse(benRegID, documentType.name()); if (existing.isPresent() && sha256Hash.equals(existing.get().getSha256Hash())) { @@ -74,11 +64,8 @@ public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String storedFileName = documentType.name() + ".enc"; String relativeDir = String.valueOf(benRegID); - // Documents must never touch disk unencrypted, not even briefly: encrypt in memory - // first, then write only the ciphertext. + // Documents must never touch disk unencrypted: encrypt in memory first, write only ciphertext. String base64Original = Base64.getEncoder().encodeToString(originalBytes); - // NOTE: reuses existing app-wide CryptoUtil (AES-128-ECB, hardcoded key) - known weaker-than-ideal - // scheme, tracked as follow-up tech debt, not addressed in this change. String encryptedPayload = cryptoUtil.encrypt(base64Original); Path dir = Paths.get(storageRoot, relativeDir); @@ -102,13 +89,10 @@ public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, try { diagnosticDocumentRepo.save(document); } catch (DataIntegrityViolationException dive) { - // Lost an upsert race to a concurrent ingest for the same beneficiary+type (e.g. a - // manual poll racing the scheduler) — the winner already persisted equivalent content. logger.warn("Lost document upsert race for benRegID={}, documentType={}", benRegID, documentType); return; } - // Audit log: benRegID/orderType/timestamp only - never raw document content or the encryption key. logger.info("Diagnostic document ingested: benRegID={}, orderType={}, epochTime={}", benRegID, orderType, epochTime); } @@ -123,8 +107,6 @@ public DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType doc Path filePath = Paths.get(storageRoot, document.getStoredPath()); String encryptedPayload = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); - // NOTE: reuses existing app-wide CryptoUtil (AES-128-ECB, hardcoded key) - known weaker-than-ideal - // scheme, tracked as follow-up tech debt, not addressed in this change. String base64Original = cryptoUtil.decrypt(encryptedPayload); if (base64Original == null) { throw new Exception("Failed to decrypt document for benRegID=" + benRegID + ", documentType=" + documentType); @@ -137,7 +119,6 @@ public DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType doc "Document integrity check failed for benRegID=" + benRegID + ", documentType=" + documentType); } - // Audit log: benRegID/documentType/timestamp only - never raw document content or the encryption key. logger.info("Diagnostic document fetched: benRegID={}, documentType={}, epochTime={}", benRegID, documentType, document.getEpochTime()); diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index d9fd0473..5ad13b59 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -50,7 +50,6 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { Long benRegID = request.getBenRegID(); Long visitCode = request.getVisitCode(); - Integer providerServiceMapID = request.getProviderServiceMapID(); DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); String orderEvent = request.getOrderEvent(); String patientFirstName = request.getPatient().getFirstName(); @@ -65,7 +64,6 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType.name()); if (existing.isPresent() && !DiagnosticOrderStatus.FAILED.name().equals(existing.get().getStatus())) { - // Idempotent: already pushed (or in flight / completed) for this visit+orderType — don't push again. return existing.get(); } @@ -73,26 +71,21 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr order.setOrderEvent(orderEvent); order.setBenRegID(benRegID); order.setVisitCode(visitCode); - order.setProviderServiceMapID(providerServiceMapID); + order.setProviderServiceName(providerCode); order.setProviderCode(providerCode); order.setOrderType(orderType.name()); order.setExternalOrderId(externalOrderId); - // Explicit reset required when reusing a previously-FAILED row: without this, a successful - // retry would leave status=FAILED (the push-success branch below only sets providerOrderId), - // and findDueForPoll only selects PENDING/IN_PROGRESS — so the order would be silently pushed - // to the real device queue but never polled again. + // Reset required when reusing a previously-FAILED row, otherwise a successful retry would + // leave status=FAILED and findDueForPoll (PENDING/IN_PROGRESS only) would never poll it. order.setStatus(DiagnosticOrderStatus.PENDING.name()); order.setErrorMessage(null); order.setPatientFirstName(patientFirstName); order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); -// order.setCreatedBy(createdBy); try { order = diagnosticOrderRepo.save(order); } catch (DataIntegrityViolationException dive) { - // Lost a create race to a concurrent identical request — return the winner idempotently - // instead of surfacing a 5000 error for what is, from the caller's perspective, a success. Optional winner = diagnosticOrderRepo .findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType.name()); if (winner.isPresent()) { @@ -100,7 +93,7 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr benRegID, visitCode, orderType, winner.get().getId()); return winner.get(); } - throw dive; // not our natural-key constraint — genuinely unexpected, don't swallow + throw dive; } try { @@ -125,12 +118,6 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr @Override public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { - // One DiagnosticResult per DiagnosticOrder — each poll updates the same row in place - // rather than accumulating a new row per attempt, since most intermediate polls before - // completion carry nothing new and DiagnosticOrder.lastPolledAt/status already tracks - // poll progress over time. The unique constraint on diagnostic_order_id also means a - // concurrent poll (e.g. this manual trigger racing the scheduler) safely loses to a - // DataIntegrityViolationException instead of creating a duplicate row. Optional existingResult = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()); DiagnosticResult result = existingResult.orElseGet(DiagnosticResult::new); result.setDiagnosticOrderId(order.getId()); @@ -140,19 +127,15 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP result.setRawResponseJson(pollResult.getRawResponseJson()); result.setTbPresence(pollResult.getTbPresence()); result.setTbConfidence(pollResult.getTbConfidence()); + result.setDrugResistancePresence(pollResult.getDrugResistancePresence()); result.setCreatedBy("SYSTEM"); try { diagnosticResultRepo.save(result); } catch (DataIntegrityViolationException dive) { - // Lost an upsert race to a concurrent poll for the same order — the winner already - // persisted an equivalent result; nothing further to do. logger.warn("Lost result upsert race for diagnosticOrderId={}", order.getId()); result = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()).orElse(result); } - // Assets can arrive before the order is fully complete (e.g. the X-ray's PRIMARY_CAPTURE - // while CAD is still pending), so ingestion runs on every poll, not gated on status. - // ingestAsset() itself de-dupes re-sent assets, so this is safe to call repeatedly. if (pollResult.getAssets() != null) { for (DiagnosticDocumentAsset asset : pollResult.getAssets()) { try { @@ -174,7 +157,7 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP diagnosticOrderRepo.save(order); DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); - dto.setDiagnosticOrderId(order.getId()); + dto.setExternalOrderId(order.getExternalOrderId()); dto.setOrderType(order.getOrderType()); dto.setStatus(order.getStatus()); dto.setErrorMessage(order.getErrorMessage()); @@ -182,6 +165,7 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP dto.setResultSummary(result.getResultSummary()); dto.setTbPresence(result.getTbPresence()); dto.setTbConfidence(result.getTbConfidence()); + dto.setDrugResistancePresence(result.getDrugResistancePresence()); return dto; } @@ -190,8 +174,6 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); DiagnosticPollResult result = provider.pollResult(order, false); if (DiagnosticOrderStatus.COMPLETED.equals(result.getStatus())) { - // Order just resolved COMPLETED on a status-only check — make the one follow-up call - // that actually fetches the report/image content. result = provider.pollResult(order, true); } return result; @@ -209,8 +191,6 @@ private DiagnosticOrder findLatestOrder(Long benRegID, String orderType) throws public DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType) throws Exception { DiagnosticOrder order = findLatestOrder(benRegID, orderType); DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); - // Ops-triggered manual poll: always fetch whatever is currently available, skip the - // status-then-assets optimization used by the scheduler. DiagnosticPollResult pollResult = provider.pollResult(order, true); return processResult(order, pollResult); } @@ -218,10 +198,35 @@ public DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderTyp @Override public DiagnosticOrder markTestCompleted(Long benRegID, String orderType) throws Exception { DiagnosticOrder order = findLatestOrder(benRegID, orderType); + String status = order.getStatus(); + if (order.getTestCompletedAt() != null) { - return order; // idempotent — already flagged, don't reset the poll clock + Optional existingResult = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()); + boolean isTerminal = DiagnosticOrderStatus.COMPLETED.name().equals(status) + || DiagnosticOrderStatus.FAILED.name().equals(status); + if (existingResult.isPresent() && isTerminal) { + // Re-test: the order already completed once and has a result on file, and the + // physical test was performed again — reopen it so the scheduler picks it back up + // and polls for the new result instead of leaving the stale one in place forever. + // Reset to PENDING (not IN_PROGRESS) so it mirrors a freshly-pushed order's + // starting state rather than looking like it's already mid-poll. + order.setTestCompletedAt(new Timestamp(System.currentTimeMillis())); + order.setStatus(DiagnosticOrderStatus.PENDING.name()); + order.setErrorMessage(null); + DiagnosticOrder savedOrder = diagnosticOrderRepo.save(order); + + // The stale result's own providerStatus also needs resetting - otherwise it keeps + // reporting the previous test's COMPLETED/FAILED status while the order itself + // shows PENDING, which is an inconsistent picture until the next poll overwrites it. + DiagnosticResult result = existingResult.get(); + result.setProviderStatus(DiagnosticOrderStatus.PENDING.name()); + diagnosticResultRepo.save(result); + + return savedOrder; + } + return order; // idempotent — already flagged, no result yet, don't reset the poll clock } - String status = order.getStatus(); + if (DiagnosticOrderStatus.COMPLETED.name().equals(status) || DiagnosticOrderStatus.FAILED.name().equals(status) || DiagnosticOrderStatus.CANCELLED.name().equals(status)) { @@ -255,7 +260,7 @@ public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType) } DiagnosticOrder order = orderOpt.get(); - dto.setDiagnosticOrderId(order.getId()); + dto.setExternalOrderId(order.getExternalOrderId()); dto.setStatus(order.getStatus()); dto.setErrorMessage(order.getErrorMessage()); @@ -264,6 +269,7 @@ public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType) dto.setResultSummary(result.getResultSummary()); dto.setTbPresence(result.getTbPresence()); dto.setTbConfidence(result.getTbConfidence()); + dto.setDrugResistancePresence(result.getDrugResistancePresence()); }); return dto; } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java index e9026c6c..8fed25a7 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -3,7 +3,6 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.masterEnum.DiagnosticOrderStatus; -import com.iemr.flw.masterEnum.DiagnosticOrderType; import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.service.DiagnosticOrderService; import org.slf4j.Logger; @@ -18,6 +17,9 @@ import java.time.temporal.ChronoUnit; import java.util.List; +// Two independent schedulers, one per order family: XRAY_CHEST polls immediately on its own +// cadence, while TrueNat waits truenatInitialDelayMinutes after test completion before polling +// on its own (slower) cadence. @Service public class DiagnosticPollSchedulerService { @@ -26,9 +28,6 @@ public class DiagnosticPollSchedulerService { @Value("${diagnostic.poll.give-up-minutes}") private int giveUpMinutes; - @Value("${diagnostic.poll.interval-seconds}") - private int pollIntervalSeconds; - @Value("${diagnostic.poll.truenat.initial-delay-minutes}") private int truenatInitialDelayMinutes; @@ -38,60 +37,61 @@ public class DiagnosticPollSchedulerService { @Autowired private DiagnosticOrderService diagnosticOrderService; - @Scheduled(fixedDelayString = "${diagnostic.poll.tick-ms:15000}") - public void pollPendingOrders() { - List candidates = diagnosticOrderRepo.findDueForPoll(); - logger.info("Diagnostic poll tick: {} order(s) in due-for-poll queue", candidates.size()); + @Scheduled(fixedDelayString = "${diagnostic.poll.xray.tick-ms:15000}") + public void pollXrayOrders() { + List candidates = diagnosticOrderRepo.findXrayDueForPoll(); + logger.info("XRAY poll tick: {} order(s) in due-for-poll queue", candidates.size()); if (candidates.isEmpty()) return; Instant now = Instant.now(); int polled = 0; for (DiagnosticOrder order : candidates) { - if (isExpired(order, now)) { + Instant testCompletedAt = order.getTestCompletedAt().toInstant(); + if (isExpired(testCompletedAt, now)) { giveUp(order); - } else if (isDue(order, now)) { + } else { pollSingle(order); polled++; } } if (polled > 0) { - logger.info("Polled {} pending diagnostic orders", polled); + logger.info("Polled {} pending XRAY diagnostic orders", polled); } } - // XRAY_CHEST: polls from the moment the test is marked completed. TrueNat: waits - // truenatInitialDelayMinutes after that (the machine's own processing time) before polling - // starts. Both then poll at the same pollIntervalSeconds cadence. - private boolean isDue(DiagnosticOrder order, Instant now) { - Instant pollingStartedAt = pollingStartedAt(order); - if (pollingStartedAt.isAfter(now)) { - return false; // still inside TrueNat's initial delay window + @Scheduled(fixedDelayString = "${diagnostic.poll.truenat.tick-ms:60000}") + public void pollTrueNatOrders() { + List candidates = diagnosticOrderRepo.findTrueNatDueForPoll(); + logger.info("TrueNat poll tick: {} order(s) in due-for-poll queue", candidates.size()); + if (candidates.isEmpty()) return; + + Instant now = Instant.now(); + int polled = 0; + for (DiagnosticOrder order : candidates) { + Instant pollingStartedAt = order.getTestCompletedAt().toInstant() + .plus(truenatInitialDelayMinutes, ChronoUnit.MINUTES); + if (pollingStartedAt.isAfter(now)) { + continue; + } + if (isExpired(pollingStartedAt, now)) { + giveUp(order); + } else { + pollSingle(order); + polled++; + } } - Timestamp lastPolledAt = order.getLastPolledAt(); - if (lastPolledAt == null) { - return true; + if (polled > 0) { + logger.info("Polled {} pending TrueNat diagnostic orders", polled); } - return !lastPolledAt.toInstant().plusSeconds(pollIntervalSeconds).isAfter(now); } - // Give-up window is measured from when polling actually starts for this order (immediately - // for X-ray, after the initial delay for TrueNat) - not from testCompletedAt directly for - // TrueNat - so a shared give-up-minutes value is meaningful for both types instead of - // auto-failing TrueNat orders before they're ever polled. - private boolean isExpired(DiagnosticOrder order, Instant now) { - Instant deadline = pollingStartedAt(order).plus(giveUpMinutes, ChronoUnit.MINUTES); + // Measured from when polling actually starts for this order (immediately for X-ray, after the + // initial delay for TrueNat), so one give-up-minutes value works for both families. + private boolean isExpired(Instant pollingStartedAt, Instant now) { + Instant deadline = pollingStartedAt.plus(giveUpMinutes, ChronoUnit.MINUTES); return !deadline.isAfter(now); } - private Instant pollingStartedAt(DiagnosticOrder order) { - Instant testCompletedAt = order.getTestCompletedAt().toInstant(); - DiagnosticOrderType type = DiagnosticOrderType.fromCode(order.getOrderType()); - if (type == DiagnosticOrderType.XRAY_CHEST) { - return testCompletedAt; - } - return testCompletedAt.plus(truenatInitialDelayMinutes, ChronoUnit.MINUTES); - } - private void giveUp(DiagnosticOrder order) { order.setStatus(DiagnosticOrderStatus.FAILED.name()); order.setErrorMessage("Polling window exceeded (" + giveUpMinutes + " min) without a result"); @@ -106,8 +106,6 @@ private void pollSingle(DiagnosticOrder order) { diagnosticOrderService.processResult(order, result); } catch (Exception e) { logger.error("Poll failed for orderId={}: {}", order.getId(), e.getMessage()); - // Purely an observability counter - does not gate future polling or mark the order - // FAILED; the give-up window above is what eventually closes out a stuck order. order.setRetryCount(order.getRetryCount() + 1); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); order.setErrorMessage(e.getMessage()); From 841867e01acc0d02132051fccf700daaada0da78 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Fri, 17 Jul 2026 18:40:49 +0530 Subject: [PATCH 39/47] Making changes in common_example.properties --- src/main/environment/common_example.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 7250eced..fd6b3620 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -1,20 +1,20 @@ server.port=8081 -server.servlet.context-path=/flw-api # FHIR Config fhir-url=http://localhost:8093/ +tm-url=http://localhost:8089/ ##--------------------------------------------## Primary db------------------------------------------------------------------- spring.datasource.url=jdbc:mysql://localhost:3306/db_iemr spring.datasource.username=root -spring.datasource.password=Hrhk@4321 +spring.datasource.password=1234 spring.datasource.driver-class-name=com.mysql.jdbc.Driver ##--------------------------------------------## Secondary db------------------------------------------------------------------- secondary.datasource.url=jdbc:mysql://localhost:3306/db_identity secondary.datasource.username=root -secondary.datasource.password=Hrhk@4321 +secondary.datasource.password=1234 secondary.datasource.driver-class-name=com.mysql.jdbc.Driver springdoc.api-docs.enabled=true From 8b05cc3496e949042b8f320bfd36ad572dbb08ad Mon Sep 17 00:00:00 2001 From: sehjotsinghunthinkable Date: Fri, 17 Jul 2026 18:43:51 +0530 Subject: [PATCH 40/47] Feature/device integration (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enforced mandatory validation for "Reason for Refusal in section e in file TbCounsellingFormSeeder * Added counselled flag to confirmed cases response, indicating whether a beneficiary has completed the TB Counselling form. Implemented using a single bulk query to avoid N+1 lookups against FormResponse. * Align `/complete` and `/submitBulk` form response editing behavior * Updated `/complete` to resolve `FormResponse` using `beneficiaryId` and `formUuid` from the request body, removing the dependency on `responseId` as a query parameter. * Updated `/submitBulk` section editing to match `/complete` behavior by performing in-place `SectionResponse` upserts and per-question delete-and-reinsert operations, instead of deleting and recreating the entire section. * Add van/sync tracking fields (vanID, parkingPlaceID, vanSerialNo, processed, syncedDate, syncedBy, syncFailureReason) to domain entities for offline mobile data collection support * feat(dynamic-form): add Hindi localization fields and completed beneficiaries endpoint - Added `sectionName_hindi`, `questionText_hindi`, and `optionValue_hindi` fields to the FormSection, SectionQuestion, and QuestionOption entities to support Hindi localization. - Added `GET /dynamicForm/response/getCompletedBeneficiaries?formType=` endpoint to return beneficiary IDs with `COMPLETE` status for the specified form type. * feat(dynamic-form): add Hindi localization fields and completed beneficiaries endpoint - Added `sectionName_hindi`, `questionText_hindi`, and `optionValue_hindi` fields to the FormSection, SectionQuestion, and QuestionOption entities to support Hindi localization. - Added `GET /dynamicForm/response/getCompletedBeneficiaries?formType=` endpoint to return beneficiary IDs with `COMPLETE` status for the specified form type. * feat(beneficiary): expose GPS coordinates in getBeneficiaryData response Add gpsLatitude, gpsLongitude, digipin, gpsTimestamp, and isGpsUnavailable fields to RMNCHBeneficiaryDetailsRmnch and RMNCHHouseHoldDetails entities, mapping to the columns written by Identity-API's /syncDataToAmrit endpoint. Enable serializeNulls on the Gson builder in BeneficiaryServiceImpl so GPS keys are always present in householdDetails and beneficiaryDetails — null for web-registered records, real values for mobile-registered ones. Co-Authored-By: Claude Sonnet 4.6 * Committed 82b00ccf with only the three files: - BeneficiaryServiceImpl.java — serializeNulls() + GPS fallback from address - RMNCHBeneficiaryDetailsRmnch.java — gpsLatitude, gpsLongitude, digipin, gpsTimestamp, isGpsUnavailable fields - RMNCHHouseHoldDetails.java — gpsLatitude, gpsLongitude fields * Revert "Committed 82b00ccf with only the three files:" This reverts commit 3040a08077d82d27dfd86f29f0fbdd4dc094ec56. * Committed 82b00ccf with only the three files: - BeneficiaryServiceImpl.java — serializeNulls() + GPS fallback from address - RMNCHBeneficiaryDetailsRmnch.java — gpsLatitude, gpsLongitude, digipin, gpsTimestamp, isGpsUnavailable fields - RMNCHHouseHoldDetails.java — gpsLatitude, gpsLongitude fields * Committed 82b00ccf with only the three files: - BeneficiaryServiceImpl.java — serializeNulls() + GPS fallback from address - RMNCHBeneficiaryDetailsRmnch.java — gpsLatitude, gpsLongitude, digipin, gpsTimestamp, isGpsUnavailable fields - RMNCHHouseHoldDetails.java — gpsLatitude, gpsLongitude fields # Conflicts: # src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java * Updated isMandatory field in Section C, so changed in TbCounsellingFormSeeder * Add optional villageId and providerServiceMapId filters to getCompletedBeneficiaries * Removing section f from seeder * Removing already existing check in /complete api * Add a CHECKBOX question type and a TB_COUNSELLING_V2 seeder with a GENERAL_INFO consent gate ahead of the counselling sections. * Seed single-checkbox questions for Sections A-D and point the Counselled lookup at TB_COUNSELLING_V2 instead of the retired V1 form * Mark form/section status REFUSED when TB2_GI_Q1=NO on /complete, split getCompletedBeneficiaries into completed/refused lists, and extract FormResponseStatus enum * Resolved conflicts * fix(stoptb): getBeneficiaryData reads anthropometry/vitals from standard tables - getBeneficiaryData now reads height/weight/bmi from t_phy_anthropometry and temperature/pulse/BP/RBS from t_phy_vitals (latest row per beneficiary) - Fallback to otherFields if exam not yet saved for the beneficiary - Stop TB specific fields (personFrom, caseFindingType etc.) still from otherFields - Add findByBeneficiaryRegIDOrderByCreatedDateDesc and findByBenVisitID to BenAnthropometryRepo and BenPhysicalVitalRepo - Add StopTB_Future_Roadmap.md for future HWC integration and edit support Co-Authored-By: Claude Sonnet 4.6 * fix(compile): rename variable 'a' to 'anthro' to avoid conflict with outer loop variable Co-Authored-By: Claude Sonnet 4.6 * fix(stoptb): set CreatedDate on t_benvisitdetail insert to prevent NOT NULL constraint violation Co-Authored-By: Claude Sonnet 4.6 * fix(anc-pnc): guard CreatedDate on BenVisitDetail insert for ANC and PNC flows If mobile does not send createdDate, ModelMapper leaves it null and MySQL rejects the insert with NOT NULL constraint. Set server timestamp as fallback. Co-Authored-By: Claude Sonnet 4.6 * fix(docker): set TZ=Asia/Kolkata to prevent UTC/IST date mismatch Without explicit timezone, Docker defaults to UTC. This causes LocalDate.now() in getOrCreateVisitForToday() to use UTC date, so evening IST registrations appear as next-day July 1 in DB and visit lookups fail after UTC midnight. Co-Authored-By: Claude Sonnet 4.6 * fix(stoptb): handle ISO 8601 Z suffix in parseDob to fix beneficiary age display Timestamp.valueOf() throws on "yyyy-MM-dd HH:mm:ss.SSSZ" format because the trailing Z is not a valid nanosecond value. Strip Z before parsing so mobile DOB like "1987-07-01T00:00:00.000Z" is correctly stored in i_ben_flow_outreach.ben_dob. Co-Authored-By: Claude Sonnet 4.6 * fix(stoptb): fallback to i_ben_flow_outreach.ben_dob when i_beneficiarydetails.dob is null Identity-API mapper (@Mapping source=dto.dob) is commented out, causing dob to be null for all mobile registrations. Fall back to ben_dob from i_ben_flow_outreach so getBeneficiaryData returns correct DOB and mobile shows correct age. Co-Authored-By: Claude Sonnet 4.6 * fix: use saveAndFlush to prevent FK violation in t_benchiefcomplaint save() leaves VisitCode UPDATE pending in JPA session — t_benchiefcomplaint INSERT runs before the UPDATE reaches DB, causing FK constraint failure and full transaction rollback. saveAndFlush forces the UPDATE to DB immediately. Co-Authored-By: Claude Sonnet 4.6 * fix(stoptb): compute age from benDetailsRMNCH_OBJ.dob instead of benDetailsOBJ.dob benDetailsOBJ.dob (i_beneficiarydetails) is null for Stop TB mobile registrations, which is why c22b3fd2 added a fallback that fills benDetailsRMNCH_OBJ.dob from i_ben_flow_outreach. The age calculation block still read the original, still-null benDetailsOBJ.dob, so dob came back correct but age/ageFull stayed 0/"". Point it at the already-corrected benDetailsRMNCH_OBJ.dob instead. Co-Authored-By: Claude Sonnet 5 * fix(stoptb): scope Suspected/Confirmed getAll by facility+village instead of userId Nurse and Counsellor are different logins hitting the same /tb/suspected/getAll and /tb/confirmed/getAll endpoints, but both queries filtered by the calling user's own userId. So a Counsellor pulling data after a Nurse creates a Suspected/Confirmed record saw none of it - only rows tied to their own login. Adds an optional providerServiceMapID(+villageID)-scoped query path on both endpoints, joined through the already-populated BenFlowStatus table (same pattern as StopTBDiagnosticsRepo), so any role at the same facility/village sees the same worklist. The old userId-based path is kept as the fallback when the new fields aren't sent, so already-deployed app builds are unaffected. * fix(stoptb): drop date filter from facility-scoped Suspected query visitDate isn't the right dimension for a facility worklist - an unresolved Suspected case from months ago is still actionable, and filtering it out by date would hide it from the Counsellor, working against the visibility fix in the previous commit. Also matches StopTBDiagnosticsRepo's facility-scoped query, which never date-filters. Old ashaId-based fallback path is untouched and still date-filters as before. * fix(stoptb): join facility-scoped query on beneficiaryID, not beneficiaryRegID TBSuspected.benId / TBConfirmedCase.benId store the person-level beneficiary_id (e.g. 907797933607), not beneficiaryRegID (e.g. 35137) - confirmed by querying i_ben_flow_outreach directly. The facility-scoped query added in the previous two commits joined against BenFlowStatus.beneficiaryRegID, so it never matched anything and always returned empty. Switched the join to BenFlowStatus.beneficiaryID, which is the correct corresponding column. * Added counselled flag to confirmed cases response, indicating whether a beneficiary has completed the TB Counselling form. Implemented using a single bulk query to avoid N+1 lookups against FormResponse. * Revert "Committed 82b00ccf with only the three files:" This reverts commit 3040a08077d82d27dfd86f29f0fbdd4dc094ec56. * Committed 82b00ccf with only the three files: - BeneficiaryServiceImpl.java — serializeNulls() + GPS fallback from address - RMNCHBeneficiaryDetailsRmnch.java — gpsLatitude, gpsLongitude, digipin, gpsTimestamp, isGpsUnavailable fields - RMNCHHouseHoldDetails.java — gpsLatitude, gpsLongitude fields * Integrated diagnostic device (X-ray/CAD/Trunat) results and document storage with the diagnostic order workflow * ● Add TrueNat TB/drug-resistance classification, split XRAY/TrueNat pollers, fix document-type collisions, and harden EMRLITE token/error handling * Making changes in common_example.properties --------- Co-authored-by: Vishwanath Balkur <118195001+vishwab1@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: vishwab1 --- src/main/environment/common_ci.properties | 22 ++ src/main/environment/common_docker.properties | 21 ++ .../environment/common_example.properties | 28 +- src/main/environment/common_local.properties | 26 +- .../DiagnosticDocumentController.java | 52 ++++ .../controller/DiagnosticOrderController.java | 113 +++++++ .../flw/domain/iemr/DiagnosticDocument.java | 74 +++++ .../iemr/flw/domain/iemr/DiagnosticOrder.java | 103 +++++++ .../domain/iemr/DiagnosticProviderToken.java | 40 +++ .../flw/domain/iemr/DiagnosticResult.java | 60 ++++ .../flw/dto/DiagnosticDocumentContent.java | 11 + .../flw/dto/DiagnosticOrderRequestDto.java | 41 +++ .../flw/dto/DiagnosticOrderResultDto.java | 19 ++ .../provider/DiagnosticDocumentAsset.java | 21 ++ .../provider/DiagnosticPollResult.java | 23 ++ .../provider/DiagnosticProvider.java | 12 + .../provider/DiagnosticProviderFactory.java | 32 ++ .../provider/DiagnosticPushResult.java | 15 + .../emrlite/EmrLiteAuthInterceptor.java | 64 ++++ .../provider/emrlite/EmrLiteProvider.java | 265 +++++++++++++++++ .../emrlite/EmrLiteRestTemplateConfig.java | 21 ++ .../provider/emrlite/EmrLiteTokenManager.java | 241 +++++++++++++++ .../emrlite/dto/EmrLiteCadRawJson.java | 33 +++ .../emrlite/dto/EmrLiteLoginRequest.java | 13 + .../emrlite/dto/EmrLiteLoginResponse.java | 13 + .../emrlite/dto/EmrLiteOrderRequest.java | 28 ++ .../emrlite/dto/EmrLiteOrderResponse.java | 14 + .../emrlite/dto/EmrLiteProviderResponse.java | 19 ++ .../emrlite/dto/EmrLiteRefreshRequest.java | 12 + .../emrlite/dto/EmrLiteRefreshResponse.java | 11 + .../emrlite/dto/EmrLiteResultRequest.java | 19 ++ .../emrlite/dto/EmrLiteResultResponse.java | 43 +++ .../masterEnum/DiagnosticDocumentType.java | 34 +++ .../flw/masterEnum/DiagnosticOrderStatus.java | 19 ++ .../flw/masterEnum/DiagnosticOrderType.java | 40 +++ .../masterEnum/DiagnosticProviderCode.java | 10 + .../flw/repo/iemr/DiagnosticDocumentRepo.java | 13 + .../flw/repo/iemr/DiagnosticOrderRepo.java | 47 +++ .../iemr/DiagnosticProviderTokenRepo.java | 13 + .../flw/repo/iemr/DiagnosticResultRepo.java | 13 + .../service/DiagnosticDocumentService.java | 13 + .../flw/service/DiagnosticOrderService.java | 27 ++ .../impl/DiagnosticDocumentServiceImpl.java | 136 +++++++++ .../impl/DiagnosticOrderServiceImpl.java | 276 ++++++++++++++++++ .../impl/DiagnosticPollSchedulerService.java | 115 ++++++++ 45 files changed, 2257 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java create mode 100644 src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java create mode 100644 src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java create mode 100644 src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java create mode 100644 src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java create mode 100644 src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java create mode 100644 src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java create mode 100644 src/main/java/com/iemr/flw/service/DiagnosticOrderService.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 36c26079..191f5401 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -40,3 +40,25 @@ sms-password=@env.SMS_PASSWORD@ send-message-url=@env.SMS_MESSAGE_URL@ #crash.logs.base.path=@env.CRASH_LOGS_PATH@ + +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=@env.DIAGNOSTIC_ACTIVE_PROVIDER@ + +diagnostic.emrlite.base-url=@env.DIAGNOSTIC_EMRLITE_BASE_URL@ +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=@env.DIAGNOSTIC_EMRLITE_USERNAME@ +diagnostic.emrlite.password=@env.DIAGNOSTIC_EMRLITE_PASSWORD@ +diagnostic.emrlite.token-ttl-seconds=@env.DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS@ +diagnostic.emrlite.refresh-ttl-seconds=@env.DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS@ + +diagnostic.poll.xray.tick-ms=@env.DIAGNOSTIC_POLL_XRAY_TICK_MS@ +diagnostic.poll.give-up-minutes=@env.DIAGNOSTIC_POLL_GIVE_UP_MINUTES@ +diagnostic.poll.interval-seconds=@env.DIAGNOSTIC_POLL_INTERVAL_SECONDS@ +diagnostic.poll.truenat.initial-delay-minutes=@env.DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES@ +diagnostic.poll.truenat.tick-ms=@env.DIAGNOSTIC_POLL_TRUENAT_TICK_MS@ + +diagnostic.documents.storage-root=@env.DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 52e09b89..2cb36687 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -40,5 +40,26 @@ send-message-url=${SMS_MESSAGE_URL} #crash.logs.base.path=${CRASH_LOGS_PATH} +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} + +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index cf40a3da..fd6b3620 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -2,7 +2,6 @@ server.port=8081 # FHIR Config fhir-url=http://localhost:8093/ - # TM Config tm-url=http://localhost:8089/ ##--------------------------------------------## Primary db------------------------------------------------------------------- @@ -24,10 +23,8 @@ springdoc.swagger-ui.enabled=true logging.path=logs/ logging.file.name=logs/flw-api.log - jwt.secret=eefa40497bb9b2a8491b85069aff62e7596521c6816e471c464181b52eac7670ff91394a - # Redis Config spring.redis.host=localhost @@ -44,7 +41,24 @@ send-message-url= crash.logs.base.path= - - - - +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} + +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} diff --git a/src/main/environment/common_local.properties b/src/main/environment/common_local.properties index f4c0261e..f6b4586d 100644 --- a/src/main/environment/common_local.properties +++ b/src/main/environment/common_local.properties @@ -71,4 +71,28 @@ sms-password= send-message-url= crash.logs.base.path=logs/ -stoptb.enforce.vanid = false \ No newline at end of file +stoptb.enforce.vanid = false + +# ── Diagnostic Device Integration ──────────────────────────────────────────── +# diagnostic.active-provider must be one of com.iemr.flw.masterEnum.DiagnosticProviderCode (currently: EMRLITE) +diagnostic.active-provider=${DIAGNOSTIC_ACTIVE_PROVIDER} + +diagnostic.emrlite.base-url=${DIAGNOSTIC_EMRLITE_BASE_URL} +diagnostic.emrlite.login-url=${diagnostic.emrlite.base-url}/api/v1/integrations/login/ +diagnostic.emrlite.refresh-url=${diagnostic.emrlite.base-url}/api/v1/integrations/getAccessToken/ +diagnostic.emrlite.order-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/ +diagnostic.emrlite.result-url=${diagnostic.emrlite.base-url}/api/v1/integrations/orders/result/ +diagnostic.emrlite.username=${DIAGNOSTIC_EMRLITE_USERNAME} +diagnostic.emrlite.password=${DIAGNOSTIC_EMRLITE_PASSWORD} +diagnostic.emrlite.token-ttl-seconds=${DIAGNOSTIC_EMRLITE_TOKEN_TTL_SECONDS} +diagnostic.emrlite.refresh-ttl-seconds=${DIAGNOSTIC_EMRLITE_REFRESH_TTL_SECONDS} + +diagnostic.poll.xray.tick-ms=${DIAGNOSTIC_POLL_XRAY_TICK_MS} +diagnostic.poll.give-up-minutes=${DIAGNOSTIC_POLL_GIVE_UP_MINUTES} +diagnostic.poll.interval-seconds=${DIAGNOSTIC_POLL_INTERVAL_SECONDS} +diagnostic.poll.truenat.initial-delay-minutes=${DIAGNOSTIC_POLL_TRUENAT_INITIAL_DELAY_MINUTES} +diagnostic.poll.truenat.tick-ms=${DIAGNOSTIC_POLL_TRUENAT_TICK_MS} + +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} + + diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java new file mode 100644 index 00000000..b96442c1 --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java @@ -0,0 +1,52 @@ +package com.iemr.flw.controller; + +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; +import com.iemr.flw.service.DiagnosticDocumentService; +import io.swagger.v3.oas.annotations.Operation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * SECURITY: this endpoint carries decrypted document bytes and MUST sit behind proper + * authentication/authorization in the real deployment. Today that's this app's existing + * JwtUserIdValidationFilter (utils/JwtUserIdValidationFilter.java), which protects every + * non-whitelisted path by default - do NOT add "/documents" to security.public-paths. + */ +@RestController +@RequestMapping("/documents") +public class DiagnosticDocumentController { + + private final Logger logger = LoggerFactory.getLogger(DiagnosticDocumentController.class); + + private final DiagnosticDocumentService diagnosticDocumentService; + + public DiagnosticDocumentController(DiagnosticDocumentService diagnosticDocumentService) { + this.diagnosticDocumentService = diagnosticDocumentService; + } + + @GetMapping + @Operation(summary = "Fetch the most recent stored document for a beneficiary + document type " + + "(XRAY_CHEST, XRAY_CHEST_ANNOTATED, CAD, MTB_REPORT, MTB_PLUS_REPORT, or MDR_RIF_REPORT)") + public ResponseEntity getDocument(@RequestParam Long benId, @RequestParam DiagnosticDocumentType documentType) { + try { + DiagnosticDocumentContent content = diagnosticDocumentService.fetch(benId, documentType); + MediaType mediaType; + try { + mediaType = MediaType.parseMediaType(content.getContentType()); + } catch (Exception e) { + mediaType = MediaType.APPLICATION_PDF; + } + return ResponseEntity.ok().contentType(mediaType).body(content.getContent()); + } catch (Exception e) { + logger.error("Error fetching document: benId={}, documentType={}: {}", benId, documentType, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } +} diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java new file mode 100644 index 00000000..bcc50d5c --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -0,0 +1,113 @@ +package com.iemr.flw.controller; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.utils.response.OutputResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/diagnostic") +public class DiagnosticOrderController { + + private final Logger logger = LoggerFactory.getLogger(DiagnosticOrderController.class); + + private final DiagnosticOrderService diagnosticOrderService; + + public DiagnosticOrderController(DiagnosticOrderService diagnosticOrderService) { + this.diagnosticOrderService = diagnosticOrderService; + } + + @PostMapping("/order/push") + @Operation(summary = "Push a diagnostic order to the configured provider") + public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.createAndPushOrder(request); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in pushOrder: {}", e.getMessage()); + response.setError(5000, "Error pushing diagnostic order: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/get") + @Operation(summary = "Get the diagnostic order for a beneficiary+orderType (latest, if more than one exists)") + public String getOrder(@RequestParam Long benRegID, @RequestParam String orderType) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.getOrder(benRegID, orderType); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in getOrder: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic order: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/getByBen/{benRegID}") + @Operation(summary = "Get all diagnostic orders for a beneficiary") + public String getOrdersByBen(@PathVariable Long benRegID) { + OutputResponse response = new OutputResponse(); + try { + List orders = diagnosticOrderService.getOrdersByBenRegId(benRegID); + response.setResponse(new Gson().toJson(orders)); + } catch (Exception e) { + logger.error("Error in getOrdersByBen: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic orders: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/testCompleted") + @Operation(summary = "Mark that the physical test has completed for this beneficiary+orderType, starting the poll cadence") + public String markTestCompleted(@RequestParam Long benRegID, @RequestParam String orderType) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrder order = diagnosticOrderService.markTestCompleted(benRegID, orderType); + response.setResponse(new Gson().toJson(order)); + } catch (Exception e) { + logger.error("Error in markTestCompleted: {}", e.getMessage()); + response.setError(5000, "Error marking test completed: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/result") + @Operation(summary = "Get the diagnostic result for benId+orderType, at whatever stage it's currently in") + public String getOrderResult(@RequestParam Long benId, @RequestParam String orderType) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrderResultDto result = diagnosticOrderService.getOrderResult(benId, orderType); + response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); + } catch (Exception e) { + logger.error("Error in getOrderResult: {}", e.getMessage()); + response.setError(5000, "Error fetching diagnostic result: " + e.getMessage()); + } + return response.toString(); + } + + @PostMapping("/order/poll") + @Operation(summary = "Trigger an immediate poll for one beneficiary+orderType's diagnostic order and return the result (ops use)") + public String pollOrder(@RequestParam Long benRegID, @RequestParam String orderType) { + OutputResponse response = new OutputResponse(); + try { + DiagnosticOrderResultDto result = diagnosticOrderService.triggerManualPoll(benRegID, orderType); + response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); + } catch (Exception e) { + logger.error("Error in pollOrder: {}", e.getMessage()); + response.setError(5000, "Error triggering poll: " + e.getMessage()); + } + return response.toString(); + } +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java new file mode 100644 index 00000000..c24e9422 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java @@ -0,0 +1,74 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_document", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"ben_reg_id", "document_type"}), + indexes = { + @Index(name = "idx_diagnostic_document_ben_reg_id", columnList = "ben_reg_id"), + @Index(name = "idx_diagnostic_document_order_type", columnList = "order_type"), + @Index(name = "idx_diagnostic_document_epoch_time", columnList = "epoch_time") +}) +@Data +public class DiagnosticDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "diagnostic_order_id") + private Long diagnosticOrderId; + + @Column(name = "ben_reg_id") + private Long benRegID; + + @Column(name = "order_type", length = 20) + private String orderType; + + @Column(name = "asset_type", length = 50) + private String assetType; + + @Column(name = "document_type", length = 30) + private String documentType; + + @Column(name = "epoch_time") + private Long epochTime; + + @Column(name = "stored_file_name", length = 150) + private String storedFileName; + + @Column(name = "stored_path", length = 255) + private String storedPath; + + @Column(name = "sha256_hash", length = 64) + private String sha256Hash; + + @Column(name = "content_type", length = 100) + private String contentType; + + @Column(name = "original_file_name", length = 255) + private String originalFileName; + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java new file mode 100644 index 00000000..5b68328e --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -0,0 +1,103 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_order", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"ben_reg_id", "visitCode", "order_type"})) +@Data +public class DiagnosticOrder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_event", length = 100) + private String orderEvent; + + @Column(name = "ben_reg_id") + private Long benRegID; + + @Column(name = "provider_service_name", length = 50) + private String providerServiceName; + + @Column(name = "provider_code", length = 50) + private String providerCode; + + @Column(name = "order_type", length = 20) + private String orderType; + + @Column(name = "external_order_id", unique = true, length = 100) + private String externalOrderId; + + @Column(name = "provider_order_id", length = 100) + private String providerOrderId; + + @Column(name = "status", length = 20) + private String status; + + @Column(name = "retry_count") + private Integer retryCount = 0; + + @Column(name = "last_polled_at") + private Timestamp lastPolledAt; + + @Column(name = "test_completed_at") + private Timestamp testCompletedAt; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "push_response_json", columnDefinition = "LONGTEXT") + private String pushResponseJson; + + @Column(name = "patient_first_name", length = 100) + private String patientFirstName; + + @Column(name = "patient_last_name", length = 100) + private String patientLastName; + + @Column(name = "patient_date_of_birth", length = 10) + private String patientDateOfBirth; + + @Column(name = "patient_sex", length = 10) + private String patientSex; + + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; + + @Column(name = "vanID") + private Integer vanID; + + @Column(name = "parkingPlaceID") + private Integer parkingPlaceID; + + @Column(name = "visitCode") + private Long visitCode; + + @Column(name = "processed") + private String processed = "N"; + + @Column(name = "vanSerialNo") + private Long vanSerialNo; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java new file mode 100644 index 00000000..906f3a57 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticProviderToken.java @@ -0,0 +1,40 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_provider_token", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "token_type"})) +@Data +public class DiagnosticProviderToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "provider_code", length = 50, nullable = false) + private String providerCode; + + // "ACCESS", "REFRESH", "API_KEY" — extensible without schema change + @Column(name = "token_type", length = 20, nullable = false) + private String tokenType; + + @Column(name = "token_value", columnDefinition = "TEXT", nullable = false) + private String tokenValue; + + @Column(name = "expires_at") + private Timestamp expiresAt; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java new file mode 100644 index 00000000..ce292753 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticResult.java @@ -0,0 +1,60 @@ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.sql.Timestamp; + +@Entity +@Table(name = "tb_diagnostic_result", schema = "db_iemr", + uniqueConstraints = @UniqueConstraint(columnNames = {"diagnostic_order_id"})) +@Data +public class DiagnosticResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "diagnostic_order_id") + private Long diagnosticOrderId; + + @Column(name = "ben_reg_id") + private Long benRegID; + + @Column(name = "provider_status", length = 20) + private String providerStatus; + + @Column(name = "result_summary", columnDefinition = "TEXT") + private String resultSummary; + + @Column(name = "raw_response_json", columnDefinition = "LONGTEXT") + private String rawResponseJson; + + @Column(name = "tb_presence") + private Boolean tbPresence; + + @Column(name = "tb_confidence") + private Double tbConfidence; + + @Column(name = "drug_resistance_presence") + private Boolean drugResistancePresence; + + @Column(name = "created_by") + private String createdBy; + + @CreationTimestamp + @Column(name = "created_date", updatable = false) + private Timestamp createdDate; + + @Column(name = "modified_by") + private String modifiedBy; + + @UpdateTimestamp + @Column(name = "last_mod_date") + private Timestamp lastModDate; + + @Column(name = "deleted") + private Boolean deleted = false; +} diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java b/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java new file mode 100644 index 00000000..c508e659 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticDocumentContent.java @@ -0,0 +1,11 @@ +package com.iemr.flw.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class DiagnosticDocumentContent { + private byte[] content; + private String contentType; +} diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java new file mode 100644 index 00000000..660ad0d5 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -0,0 +1,41 @@ +package com.iemr.flw.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class DiagnosticOrderRequestDto { + + @NotNull + private Long benRegID; + + @NotNull + private Long visitCode; + + @NotNull + private String orderType; + + private String orderEvent; + + @NotNull + @Valid + private PatientDto patient; + + @Data + @NoArgsConstructor + public static class PatientDto { + @NotBlank private String firstName; + @NotBlank private String lastName; + @NotBlank + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "dateOfBirth must be in yyyy-MM-dd format") + private String dateOfBirth; + @NotBlank + @Pattern(regexp = "Male|Female|Others", message = "sex must be exactly one of Male, Female, Others") + private String sex; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java new file mode 100644 index 00000000..25b3d658 --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -0,0 +1,19 @@ +package com.iemr.flw.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class DiagnosticOrderResultDto { + + private String externalOrderId; + private String orderType; + private String status; + private String providerStatus; + private String resultSummary; + private String errorMessage; + private Boolean tbPresence; + private Double tbConfidence; + private Boolean drugResistancePresence; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java new file mode 100644 index 00000000..a43d1d55 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticDocumentAsset.java @@ -0,0 +1,21 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Source-agnostic document payload accepted by DiagnosticDocumentService.ingestAsset(...). + * Not tied to any specific provider's DTO shape (e.g. EMR Lite's AssetDto) or to any + * particular polling flow — deliberately decoupled so it can be fed from wherever the + * caller ends up being wired in. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticDocumentAsset { + private String type; + private String contentType; + private String fileName; + private String base64Content; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java new file mode 100644 index 00000000..9f652bf6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPollResult.java @@ -0,0 +1,23 @@ +package com.iemr.flw.integration.provider; + +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticPollResult { + private DiagnosticOrderStatus status; + private String providerOrderId; + private String resultSummary; + private String rawResponseJson; + private List assets; + private String errorMessage; + private Boolean tbPresence; + private Double tbConfidence; + private Boolean drugResistancePresence; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java new file mode 100644 index 00000000..86bf1cbc --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java @@ -0,0 +1,12 @@ +package com.iemr.flw.integration.provider; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; + +public interface DiagnosticProvider { + + String getProviderCode(); + + DiagnosticPushResult pushOrder(DiagnosticOrder order) throws Exception; + + DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java new file mode 100644 index 00000000..0c92c5bb --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProviderFactory.java @@ -0,0 +1,32 @@ +package com.iemr.flw.integration.provider; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class DiagnosticProviderFactory { + + @Autowired + private List providers; + + private Map registry; + + @PostConstruct + public void init() { + registry = providers.stream() + .collect(Collectors.toMap(DiagnosticProvider::getProviderCode, p -> p)); + } + + public DiagnosticProvider getProvider(String providerCode) { + DiagnosticProvider provider = registry.get(providerCode); + if (provider == null) { + throw new IllegalArgumentException("No diagnostic provider registered for code: " + providerCode); + } + return provider; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java new file mode 100644 index 00000000..6920b87d --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticPushResult.java @@ -0,0 +1,15 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticPushResult { + private boolean success; + private String providerOrderId; + private String rawResponseJson; + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java new file mode 100644 index 00000000..eb2e7c36 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteAuthInterceptor.java @@ -0,0 +1,64 @@ +package com.iemr.flw.integration.provider.emrlite; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** + * Attaches the current EMR Lite bearer token to every outgoing request and, on a 401, + * forces a token refresh and retries once. Scoped to the emrLiteRestTemplate bean only — + * must not be reused for other providers' RestTemplate instances. + */ +public class EmrLiteAuthInterceptor implements ClientHttpRequestInterceptor { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteAuthInterceptor.class); + + private final EmrLiteTokenManager tokenManager; + + public EmrLiteAuthInterceptor(EmrLiteTokenManager tokenManager) { + this.tokenManager = tokenManager; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) + throws IOException { + request.getHeaders().setBearerAuth(getValidToken()); + ClientHttpResponse response = execution.execute(request, body); + if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) { + logger.warn("Received 401 from EMR Lite, foremrLiteRestTemplatecing token refresh and retrying"); + response.close(); + request.getHeaders().setBearerAuth(forceRefresh()); + return execution.execute(request, body); + } + return response; + } + + private String getValidToken() throws IOException { + try { + return tokenManager.getValidToken(); + } catch (Exception e) { + // Logged here (with full stack trace) because every current caller up the chain + // (DiagnosticOrderServiceImpl, DiagnosticPollSchedulerService) only logs e.getMessage(), + // which would otherwise show just this method's generic wrapper text. Folding the real + // cause into the message too means those shallow logs become useful without having to + // touch every call site. + logger.error("Unable to obtain EMR Lite token", e); + throw new IOException("Unable to obtain EMR Lite token: " + e.getMessage(), e); + } + } + + private String forceRefresh() throws IOException { + try { + return tokenManager.forceRefresh(); + } catch (Exception e) { + logger.error("Unable to refresh EMR Lite token", e); + throw new IOException("Unable to refresh EMR Lite token: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java new file mode 100644 index 00000000..2dc65eea --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -0,0 +1,265 @@ +package com.iemr.flw.integration.provider.emrlite; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.integration.provider.DiagnosticProvider; +import com.iemr.flw.integration.provider.DiagnosticPushResult; +import com.iemr.flw.integration.provider.emrlite.dto.*; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.masterEnum.DiagnosticProviderCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class EmrLiteProvider implements DiagnosticProvider { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteProvider.class); + private static final String PROVIDER_CODE = DiagnosticProviderCode.EMRLITE.name(); + private static final String CAD_COMPONENT_KEY = "cad"; + private static final String TUBERCULOSIS_FINDING_NAME = "Tuberculosis"; + + private static final String SUMMARY_TB_POSITIVE = "TB Positive"; + private static final String SUMMARY_DR_TB = "DR TB"; + + @Value("${diagnostic.emrlite.order-url}") + private String orderUrl; + + @Value("${diagnostic.emrlite.result-url}") + private String resultUrl; + + @Autowired + @Qualifier("emrLiteRestTemplate") + private RestTemplate restTemplate; + + private final Gson gson = new Gson(); + + @Override + public String getProviderCode() { + return PROVIDER_CODE; + } + + @Override + public DiagnosticPushResult pushOrder(DiagnosticOrder order) throws Exception { + EmrLiteOrderRequest request = buildOrderRequest(order); + String responseBody; + try { + responseBody = doPost(orderUrl, gson.toJson(request)); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + String providerMessage = null; + String errorDetail = ""; + try { + EmrLiteProviderResponse errorEnvelope = gson.fromJson(body, EmrLiteProviderResponse.class); + if (errorEnvelope != null) { + providerMessage = errorEnvelope.getMessage(); + errorDetail = describeErrorData(errorEnvelope.getData()); + } + } catch (Exception parseEx) { + logger.debug("Could not parse provider error body as envelope JSON for externalOrderId={}", + order.getExternalOrderId()); + } + String errorMessage = (providerMessage != null && !providerMessage.isBlank()) + ? "HTTP " + e.getStatusCode() + ": " + providerMessage + errorDetail + : "HTTP " + e.getStatusCode() + errorDetail; + logger.warn("Provider HTTP error on order push: externalOrderId={}, status={}, message={}", + order.getExternalOrderId(), e.getStatusCode(), providerMessage); + return new DiagnosticPushResult(false, null, body, errorMessage); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (!envelope.isSuccess()) { + logger.warn("Provider rejected order push: externalOrderId={}, message={}", + order.getExternalOrderId(), envelope.getMessage()); + return new DiagnosticPushResult(false, null, responseBody, envelope.getMessage()); + } + + EmrLiteOrderResponse orderResponse = gson.fromJson(envelope.getData(), EmrLiteOrderResponse.class); + logger.info("Order pushed: externalOrderId={}, status={}", + order.getExternalOrderId(), orderResponse.getStatus()); + return new DiagnosticPushResult(true, orderResponse.getExternalOrderId(), responseBody, null); + } + + @Override + public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception { + EmrLiteResultRequest request = new EmrLiteResultRequest(order.getExternalOrderId(), includeAssets); + String responseBody; + try { + responseBody = doPost(resultUrl, gson.toJson(request)); + } catch (HttpClientErrorException.NotFound e) { + // Permanent per the API contract — return FAILED instead of throwing, so the scheduler + // stops instead of retrying on the transient back-off schedule. + logger.warn("Provider returned 404 Not Found for externalOrderId={}", order.getExternalOrderId()); + return new DiagnosticPollResult(DiagnosticOrderStatus.FAILED, null, null, + e.getResponseBodyAsString(), null, + "Order not found on provider (404): externalOrderId=" + order.getExternalOrderId(), + false, null, false); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (!envelope.isSuccess()) { + throw new Exception("Provider rejected result poll: " + envelope.getMessage()); + } + + EmrLiteResultResponse result = gson.fromJson(envelope.getData(), EmrLiteResultResponse.class); + DiagnosticOrderStatus status = deriveOrderStatus(result); + + String summary = result.getResult() != null ? result.getResult().getSummary() : null; + List assets = mapAssets(result); + EmrLiteCadRawJson.FindingDto tbFinding = extractTuberculosisFinding(result); + Boolean tbPresence = tbFinding != null ? tbFinding.isPresence() : null; + Double tbConfidence = tbFinding != null ? tbFinding.getConfidence() : null; + if (tbPresence == null) { + tbPresence = classifyTbPresenceFromSummary(order.getOrderType(), summary); + } + Boolean drugResistancePresence = classifyDrugResistanceFromSummary(order.getOrderType(), summary); + + return new DiagnosticPollResult(status, order.getExternalOrderId(), summary, responseBody, assets, null, + tbPresence, tbConfidence, drugResistancePresence); + } + + private Boolean classifyTbPresenceFromSummary(String orderType, String summary) { + if (!DiagnosticOrderType.MTB.name().equals(orderType) && !DiagnosticOrderType.MTB_PLUS.name().equals(orderType)) { + return false; + } + return SUMMARY_TB_POSITIVE.equalsIgnoreCase(summary); + } + + // MDR_RIF's summary reports rifampicin drug-resistance, not raw TB presence — kept as its own + // field rather than folded into tbPresence. + private Boolean classifyDrugResistanceFromSummary(String orderType, String summary) { + if (!DiagnosticOrderType.MDR_RIF.name().equals(orderType)) { + return false; + } + return SUMMARY_DR_TB.equalsIgnoreCase(summary); + } + + private EmrLiteCadRawJson.FindingDto extractTuberculosisFinding(EmrLiteResultResponse result) { + if (result.getComponents() == null || !result.getComponents().containsKey(CAD_COMPONENT_KEY)) { + return null; + } + if (result.getResult() == null || result.getResult().getRawJson() == null) { + return null; + } + try { + EmrLiteCadRawJson cadRawJson = + gson.fromJson(gson.toJson(result.getResult().getRawJson()), EmrLiteCadRawJson.class); + if (cadRawJson.getResults() == null || cadRawJson.getResults().getFindings() == null) { + return null; + } + return cadRawJson.getResults().getFindings().stream() + .filter(f -> TUBERCULOSIS_FINDING_NAME.equalsIgnoreCase(f.getName())) + .findFirst() + .orElse(null); + } catch (Exception e) { + logger.warn("Failed to parse CAD rawJson findings for externalOrderId={}: {}", + result.getExternalOrderId(), e.getMessage()); + return null; + } + } + + // Provider's top-level "status" is unreliable on its own (can report COMPLETED while a + // component is still PENDING), so true completion is derived from the components map instead. + private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { + Map components = result.getComponents(); + if (components == null || components.isEmpty()) { + return DiagnosticOrderStatus.fromString(result.getStatus()); + } + boolean anyFailed = components.values().stream() + .anyMatch(c -> "FAILED".equalsIgnoreCase(c.getStatus())); + if (anyFailed) { + return DiagnosticOrderStatus.FAILED; + } + boolean allCompleted = components.values().stream() + .allMatch(c -> "COMPLETED".equalsIgnoreCase(c.getStatus())); + if (allCompleted) { + return DiagnosticOrderStatus.COMPLETED; + } + DiagnosticOrderStatus topLevelStatus = DiagnosticOrderStatus.fromString(result.getStatus()); + if (topLevelStatus == DiagnosticOrderStatus.COMPLETED || topLevelStatus == DiagnosticOrderStatus.FAILED) { + // Components contradict a terminal top-level status — only the components map may + // resolve to a terminal state; treat this as still in progress. + return DiagnosticOrderStatus.IN_PROGRESS; + } + return topLevelStatus; + } + + private String describeErrorData(JsonElement data) { + if (data == null || !data.isJsonObject()) { + return ""; + } + JsonObject dataObject = data.getAsJsonObject(); + JsonElement code = dataObject.get("code"); + JsonElement field = dataObject.get("field"); + if (code == null && field == null) { + return ""; + } + StringBuilder detail = new StringBuilder(" ("); + if (code != null) { + detail.append("code=").append(code.getAsString()); + } + if (field != null) { + if (code != null) { + detail.append(", "); + } + detail.append("field=").append(field.getAsString()); + } + return detail.append(")").toString(); + } + + private List mapAssets(EmrLiteResultResponse result) { + if (result.getResult() == null || result.getResult().getAssets() == null) { + return new ArrayList<>(); + } + List assets = new ArrayList<>(); + for (EmrLiteResultResponse.AssetDto assetDto : result.getResult().getAssets()) { + assets.add(new DiagnosticDocumentAsset( + assetDto.getType(), assetDto.getContentType(), assetDto.getFileName(), assetDto.getBase64())); + } + return assets; + } + + private String doPost(String url, String jsonBody) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + return response.getBody(); + } + + private EmrLiteOrderRequest buildOrderRequest(DiagnosticOrder order) { + EmrLiteOrderRequest.PatientDto patient = new EmrLiteOrderRequest.PatientDto( + order.getPatientFirstName(), + order.getPatientLastName(), + order.getPatientDateOfBirth(), + order.getPatientSex()); + String orderedAt = OffsetDateTime.now(ZoneId.of("Asia/Kolkata")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + return new EmrLiteOrderRequest( + String.valueOf(order.getBenRegID()), + String.valueOf(order.getVisitCode()), + order.getExternalOrderId(), + order.getOrderType(), + orderedAt, + patient); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java new file mode 100644 index 00000000..cbc9993c --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java @@ -0,0 +1,21 @@ +package com.iemr.flw.integration.provider.emrlite; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +/** + * Dedicated RestTemplate for EMR Lite diagnostic provider calls only. + * Do NOT reuse this bean for other integrations (TM API, FHIR, etc.) — EmrLiteAuthInterceptor + * unconditionally attaches an EMR Lite bearer token to every request that goes through it. + */ +@Configuration +public class EmrLiteRestTemplateConfig { + + @Bean + public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new EmrLiteAuthInterceptor(emrLiteTokenManager)); + return restTemplate; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java new file mode 100644 index 00000000..2b0b3dd6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteTokenManager.java @@ -0,0 +1,241 @@ +package com.iemr.flw.integration.provider.emrlite; + +import com.google.gson.Gson; +import com.iemr.flw.domain.iemr.DiagnosticProviderToken; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteLoginRequest; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteLoginResponse; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteProviderResponse; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteRefreshRequest; +import com.iemr.flw.integration.provider.emrlite.dto.EmrLiteRefreshResponse; +import com.iemr.flw.masterEnum.DiagnosticProviderCode; +import com.iemr.flw.repo.iemr.DiagnosticProviderTokenRepo; +import com.iemr.flw.utils.CryptoUtil; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Optional; + +@Component +public class EmrLiteTokenManager { + + private static final Logger logger = LoggerFactory.getLogger(EmrLiteTokenManager.class); + private static final String PROVIDER_CODE = DiagnosticProviderCode.EMRLITE.name(); + private static final String TYPE_ACCESS = "ACCESS"; + private static final String TYPE_REFRESH = "REFRESH"; + + @Value("${diagnostic.emrlite.login-url}") + private String loginUrl; + + @Value("${diagnostic.emrlite.refresh-url}") + private String refreshUrl; + + @Value("${diagnostic.emrlite.username}") + private String username; + + @Value("${diagnostic.emrlite.password}") + private String encodedPassword; + + @Value("${diagnostic.emrlite.token-ttl-seconds:3600}") + private long tokenTtlSeconds; + + @Value("${diagnostic.emrlite.refresh-ttl-seconds:82800}") + private long refreshTtlSeconds; + + private String password; + + @Autowired + private DiagnosticProviderTokenRepo tokenRepo; + + @Autowired + private CryptoUtil cryptoUtil; + + private final RestTemplate restTemplate = new RestTemplate(); + private final Gson gson = new Gson(); + + @PostConstruct + private void init() { + this.password = encodedPassword != null && encodedPassword.startsWith("0X10:") + ? new String(Base64.getDecoder().decode(encodedPassword.substring(5))) + : encodedPassword; + } + + public String getValidToken() throws Exception { + Optional record = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_ACCESS); + if (record.isPresent()) { + DiagnosticProviderToken token = record.get(); + boolean expired = token.getExpiresAt() == null + || Instant.now().isAfter(token.getExpiresAt().toInstant().minusSeconds(60)); + if (!expired) { + return cryptoUtil.decrypt(token.getTokenValue()); + } + } + synchronized (this) { + Optional recheck = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_ACCESS); + if (recheck.isPresent()) { + DiagnosticProviderToken t = recheck.get(); + boolean stillExpired = t.getExpiresAt() == null + || Instant.now().isAfter(t.getExpiresAt().toInstant().minusSeconds(60)); + if (!stillExpired) { + return cryptoUtil.decrypt(t.getTokenValue()); + } + } + return refreshOrLogin(); + } + } + + public synchronized String forceRefresh() throws Exception { + return refreshOrLogin(); + } + + private String refreshOrLogin() throws Exception { + try { + return refreshAccessToken(); + } catch (Exception e) { + logger.warn("Token refresh failed ({}), falling back to full login", e.getMessage()); + return login(); + } + } + + private String refreshAccessToken() throws Exception { + Optional refreshRecord = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, TYPE_REFRESH); + if (refreshRecord.isEmpty()) { + throw new Exception("No refresh token in DB — need full login"); + } + DiagnosticProviderToken refreshToken = refreshRecord.get(); + boolean refreshExpired = refreshToken.getExpiresAt() == null + || Instant.now().isAfter(refreshToken.getExpiresAt().toInstant().minusSeconds(60)); + if (refreshExpired) { + throw new Exception("Refresh token expired — need full login"); + } + String rawRefresh = cryptoUtil.decrypt(refreshToken.getTokenValue()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(gson.toJson(new EmrLiteRefreshRequest(rawRefresh)), headers); + + ResponseEntity response; + try { + response = restTemplate.exchange(refreshUrl, HttpMethod.POST, entity, String.class); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.warn("EMR Lite refresh rejected by provider: url={}, status={}, body={}", + refreshUrl, e.getStatusCode(), body); + throw new Exception("Refresh token rejected by provider (HTTP " + e.getStatusCode() + ") - " + body, e); + } catch (RestClientException e) { + logger.warn("EMR Lite refresh request failed (network/connection error): url={}", refreshUrl, e); + throw new Exception("EMR Lite refresh request failed: " + e.getMessage(), e); + } + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + logger.warn("EMR Lite refresh failed: url={}, status={}, body={}", + refreshUrl, response.getStatusCode(), response.getBody()); + throw new Exception("Token refresh failed with status: " + response.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(response.getBody(), EmrLiteProviderResponse.class); + if (envelope == null) { + logger.warn("EMR Lite refresh response could not be parsed: url={}, rawBody={}", refreshUrl, response.getBody()); + throw new Exception("EMR Lite refresh response could not be parsed, rawBody=" + response.getBody()); + } + if (!envelope.isSuccess()) { + logger.warn("EMR Lite refresh rejected: url={}, message={}, rawBody={}", + refreshUrl, envelope.getMessage(), response.getBody()); + throw new Exception("Token refresh rejected: " + envelope.getMessage()); + } + + EmrLiteRefreshResponse refreshResponse = gson.fromJson(envelope.getData(), EmrLiteRefreshResponse.class); + if (refreshResponse.getAccess() == null) { + throw new Exception("Refresh response returned null access token"); + } + + persistToken(TYPE_ACCESS, refreshResponse.getAccess(), + Timestamp.from(Instant.now().plus(tokenTtlSeconds, ChronoUnit.SECONDS))); + + logger.info("EMR Lite access token refreshed, TTL {}s", tokenTtlSeconds); + return refreshResponse.getAccess(); + } + + private String login() throws Exception { + logger.info("Attempting EMR Lite login: url={}, username={}", loginUrl, username); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>( + gson.toJson(new EmrLiteLoginRequest(username, password)), headers); + + ResponseEntity response; + try { + response = restTemplate.exchange(loginUrl, HttpMethod.POST, entity, String.class); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.error("EMR Lite login rejected by provider: url={}, status={}, body={}", + loginUrl, e.getStatusCode(), body); + throw new Exception("EMR Lite login failed: HTTP " + e.getStatusCode() + " - " + body, e); + } catch (RestClientException e) { + logger.error("EMR Lite login request failed (network/connection error): url={}", loginUrl, e); + throw new Exception("EMR Lite login request failed: " + e.getMessage(), e); + } + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + logger.error("EMR Lite login failed: url={}, status={}, body={}", + loginUrl, response.getStatusCode(), response.getBody()); + throw new Exception("EMR Lite login failed with status: " + response.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(response.getBody(), EmrLiteProviderResponse.class); + if (envelope == null) { + logger.error("EMR Lite login response could not be parsed: url={}, rawBody={}", loginUrl, response.getBody()); + throw new Exception("EMR Lite login response could not be parsed, rawBody=" + response.getBody()); + } + if (!envelope.isSuccess()) { + logger.error("EMR Lite login rejected: url={}, message={}, rawBody={}", + loginUrl, envelope.getMessage(), response.getBody()); + throw new Exception("EMR Lite login rejected: " + envelope.getMessage()); + } + + EmrLiteLoginResponse tokens = gson.fromJson(envelope.getData(), EmrLiteLoginResponse.class); + if (tokens == null || tokens.getAccessToken() == null) { + logger.error("EMR Lite login returned no accessToken: url={}, rawBody={}", loginUrl, response.getBody()); + throw new Exception("Login returned null accessToken"); + } + + long accessTtlSeconds = tokens.getExpiresIn() != null ? tokens.getExpiresIn() : tokenTtlSeconds; + persistToken(TYPE_ACCESS, tokens.getAccessToken(), + Timestamp.from(Instant.now().plus(accessTtlSeconds, ChronoUnit.SECONDS))); + if (tokens.getRefreshToken() != null) { + persistToken(TYPE_REFRESH, tokens.getRefreshToken(), + Timestamp.from(Instant.now().plus(refreshTtlSeconds, ChronoUnit.SECONDS))); + } + + logger.info("EMR Lite login successful, access TTL {}s (from {}), refresh TTL {}s", accessTtlSeconds, + tokens.getExpiresIn() != null ? "provider" : "config", refreshTtlSeconds); + return tokens.getAccessToken(); + } + + private void persistToken(String tokenType, String rawValue, Timestamp expiresAt) throws Exception { + String encrypted = cryptoUtil.encrypt(rawValue); + Optional existing = + tokenRepo.findByProviderCodeAndTokenType(PROVIDER_CODE, tokenType); + DiagnosticProviderToken record = existing.orElseGet(DiagnosticProviderToken::new); + record.setProviderCode(PROVIDER_CODE); + record.setTokenType(tokenType); + record.setTokenValue(encrypted); + record.setExpiresAt(expiresAt); + tokenRepo.save(record); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java new file mode 100644 index 00000000..a8e98754 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCadRawJson.java @@ -0,0 +1,33 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Typed view over EmrLiteResultResponse.ResultDto.rawJson, used only to pull the CAD + * (e.g. DRONGOAI) findings list back out of that otherwise-opaque blob. Only the fields + * needed for that extraction are declared - unmapped JSON fields (image, metadata, ...) + * are ignored by Gson rather than modeled here. + */ +@Data +@NoArgsConstructor +public class EmrLiteCadRawJson { + + private ResultsDto results; + + @Data + @NoArgsConstructor + public static class ResultsDto { + private List findings; + } + + @Data + @NoArgsConstructor + public static class FindingDto { + private String name; + private boolean presence; + private double confidence; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java new file mode 100644 index 00000000..35aa37d8 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginRequest.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteLoginRequest { + private String username; + private String password; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java new file mode 100644 index 00000000..c36e519b --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteLoginResponse.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteLoginResponse { + private String accessToken; + private String refreshToken; + private String tokenType; + private Integer expiresIn; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java new file mode 100644 index 00000000..e3b6ae9b --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderRequest.java @@ -0,0 +1,28 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteOrderRequest { + + private String externalPatientId; + private String externalVisitId; + private String externalOrderId; + private String orderType; + private String orderedAt; + private PatientDto patient; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class PatientDto { + private String firstName; + private String lastName; + private String dateOfBirth; + private String sex; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java new file mode 100644 index 00000000..4a791c38 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteOrderResponse.java @@ -0,0 +1,14 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteOrderResponse { + private String externalPatientId; + private String externalVisitId; + private String externalOrderId; + private String orderType; + private String status; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java new file mode 100644 index 00000000..0c34267f --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteProviderResponse.java @@ -0,0 +1,19 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import com.google.gson.JsonElement; +import lombok.Data; +import lombok.NoArgsConstructor; + +// Data is JsonElement, not JsonObject, since the API sometimes returns it as a plain string +// (e.g. error cases) instead of an object. +@Data +@NoArgsConstructor +public class EmrLiteProviderResponse { + private String Result; + private JsonElement Data; + private String Message; + + public boolean isSuccess() { + return "Success".equalsIgnoreCase(Result); + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java new file mode 100644 index 00000000..47fb32c6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshRequest.java @@ -0,0 +1,12 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteRefreshRequest { + private String refresh; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java new file mode 100644 index 00000000..3a69510e --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteRefreshResponse.java @@ -0,0 +1,11 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteRefreshResponse { + // Field name is "access" (not "accessToken") per the getAccessToken API contract + private String access; +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java new file mode 100644 index 00000000..a7dcd706 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultRequest.java @@ -0,0 +1,19 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EmrLiteResultRequest { + private String externalOrderId; + // "base64" includes report/image file content in the response's assets array; "none" returns + // status/summary only. Callers pass true only for the one follow-up call made once an order + // resolves COMPLETED - every other poll is a cheap status-only check. + private String includeAssets; + + public EmrLiteResultRequest(String externalOrderId, boolean includeAssets) { + this.externalOrderId = externalOrderId; + this.includeAssets = includeAssets ? "base64" : "none"; + } +} diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java new file mode 100644 index 00000000..a9b33bb6 --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteResultResponse.java @@ -0,0 +1,43 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +public class EmrLiteResultResponse { + + private String externalOrderId; + private String orderType; + private String status; + private Map components; + private ResultDto result; + + @Data + @NoArgsConstructor + public static class ComponentStatus { + private String status; + private String provider; + } + + @Data + @NoArgsConstructor + public static class ResultDto { + private String summary; + private Object rawJson; + private String reportedAt; + private List assets; + } + + @Data + @NoArgsConstructor + public static class AssetDto { + private String type; + private String contentType; + private String fileName; + private String base64; + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java new file mode 100644 index 00000000..5416df34 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java @@ -0,0 +1,34 @@ +package com.iemr.flw.masterEnum; + +// XRAY_CHEST orders can carry up to three distinct artifacts (raw capture, AI-annotated capture, +// CAD report), each needing its own value under the (benRegID, documentType) storage key. +public enum DiagnosticDocumentType { + XRAY_CHEST, + XRAY_CHEST_ANNOTATED, + CAD, + MTB_REPORT, + MTB_PLUS_REPORT, + MDR_RIF_REPORT; + + private static final String REPORT_ASSET_TYPE = "REPORT"; + private static final String SECONDARY_CAPTURE_ASSET_TYPE = "SECONDARY_CAPTURE"; + + public static DiagnosticDocumentType from(String orderType, String assetType) { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + if (type == DiagnosticOrderType.XRAY_CHEST) { + if (REPORT_ASSET_TYPE.equalsIgnoreCase(assetType)) { + return CAD; + } + if (SECONDARY_CAPTURE_ASSET_TYPE.equalsIgnoreCase(assetType)) { + return XRAY_CHEST_ANNOTATED; + } + return XRAY_CHEST; + } + switch (type) { + case MTB: return MTB_REPORT; + case MTB_PLUS: return MTB_PLUS_REPORT; + case MDR_RIF: return MDR_RIF_REPORT; + default: throw new IllegalArgumentException("Unknown orderType for document classification: " + orderType); + } + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java new file mode 100644 index 00000000..89820c24 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -0,0 +1,19 @@ +package com.iemr.flw.masterEnum; + +public enum DiagnosticOrderStatus { + PENDING, + IN_PROGRESS, + COMPLETED, + FAILED, + CANCELLED; + + public static DiagnosticOrderStatus fromString(String value) { + if (value == null) return PENDING; + for (DiagnosticOrderStatus status : values()) { + if (status.name().equalsIgnoreCase(value)) { + return status; + } + } + return PENDING; + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java new file mode 100644 index 00000000..46922326 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderType.java @@ -0,0 +1,40 @@ +package com.iemr.flw.masterEnum; + +public enum DiagnosticOrderType { + XRAY_CHEST(1), + MTB(2), + MTB_PLUS(3), + MDR_RIF(4); + + private final int id; + + DiagnosticOrderType(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public String getCode() { + return this.name(); + } + + public static DiagnosticOrderType fromId(int id) { + for (DiagnosticOrderType type : values()) { + if (type.id == id) { + return type; + } + } + throw new IllegalArgumentException("Invalid DiagnosticOrderType ID: " + id); + } + + public static DiagnosticOrderType fromCode(String code) { + for (DiagnosticOrderType type : values()) { + if (type.name().equalsIgnoreCase(code)) { + return type; + } + } + throw new IllegalArgumentException("Invalid DiagnosticOrderType code: " + code); + } +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java new file mode 100644 index 00000000..a3a5a644 --- /dev/null +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticProviderCode.java @@ -0,0 +1,10 @@ +package com.iemr.flw.masterEnum; + +/** + * Registered diagnostic device integration providers. Add a new constant here when wiring up a + * new DiagnosticProvider implementation (see DiagnosticProviderFactory) - diagnostic.active-provider + * in application properties must be set to one of these names. + */ +public enum DiagnosticProviderCode { + EMRLITE +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java new file mode 100644 index 00000000..e97a188f --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticDocument; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticDocumentRepo extends JpaRepository { + + Optional findByBenRegIDAndDocumentTypeAndDeletedFalse(Long benRegID, String documentType); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java new file mode 100644 index 00000000..7a892877 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -0,0 +1,47 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DiagnosticOrderRepo extends JpaRepository { + + Optional findByExternalOrderId(String externalOrderId); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.benRegID = :benRegID AND o.visitCode = :visitCode " + + "AND o.orderType = :orderType AND o.deleted = false") + Optional findByBenRegIDAndVisitCodeAndOrderType(@Param("benRegID") Long benRegID, + @Param("visitCode") Long visitCode, + @Param("orderType") String orderType); + + Optional findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + Long benRegID, String orderType); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.benRegID = :benRegID AND o.deleted = false ORDER BY o.createdDate DESC") + List findByBenRegID(@Param("benRegID") Long benRegID); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType = 'XRAY_CHEST' " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + + "AND o.deleted = false AND o.testCompletedAt IS NOT NULL " + + "ORDER BY o.lastPolledAt ASC NULLS FIRST") + List findXrayDueForPoll(); + + @Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType IN ('MTB', 'MTB_PLUS', 'MDR_RIF') " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + + "AND o.deleted = false AND o.testCompletedAt IS NOT NULL " + + "ORDER BY o.lastPolledAt ASC NULLS FIRST") + List findTrueNatDueForPoll(); + + @Transactional + @Modifying + @Query("UPDATE DiagnosticOrder o SET o.vanSerialNo = o.id WHERE o.id = :id") + void updateVanSerialNo(@Param("id") Long id); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java new file mode 100644 index 00000000..8f029fd8 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticProviderTokenRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticProviderToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticProviderTokenRepo extends JpaRepository { + + Optional findByProviderCodeAndTokenType(String providerCode, String tokenType); +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java new file mode 100644 index 00000000..c8796ff5 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticResultRepo.java @@ -0,0 +1,13 @@ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DiagnosticResult; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DiagnosticResultRepo extends JpaRepository { + + Optional findByDiagnosticOrderIdAndDeletedFalse(Long diagnosticOrderId); +} diff --git a/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java b/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java new file mode 100644 index 00000000..f771afa6 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java @@ -0,0 +1,13 @@ +package com.iemr.flw.service; + +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; + +public interface DiagnosticDocumentService { + + void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String externalOrderId, + DiagnosticDocumentAsset asset) throws Exception; + + DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType) throws Exception; +} diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java new file mode 100644 index 00000000..24c18525 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -0,0 +1,27 @@ +package com.iemr.flw.service; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.integration.provider.DiagnosticPollResult; + +import java.util.List; + +public interface DiagnosticOrderService { + + DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception; + + DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult result) throws Exception; + + DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception; + + DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType) throws Exception; + + DiagnosticOrder markTestCompleted(Long benRegID, String orderType) throws Exception; + + DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType); + + DiagnosticOrder getOrder(Long benRegID, String orderType) throws Exception; + + List getOrdersByBenRegId(Long benRegID) throws Exception; +} diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java new file mode 100644 index 00000000..665d426b --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -0,0 +1,136 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticDocument; +import com.iemr.flw.dto.DiagnosticDocumentContent; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.masterEnum.DiagnosticDocumentType; +import com.iemr.flw.repo.iemr.DiagnosticDocumentRepo; +import com.iemr.flw.service.DiagnosticDocumentService; +import com.iemr.flw.utils.CryptoUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +@Service +public class DiagnosticDocumentServiceImpl implements DiagnosticDocumentService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticDocumentServiceImpl.class); + private static final String DEFAULT_CONTENT_TYPE = "application/pdf"; + + @Value("${diagnostic.documents.storage-root}") + private String storageRoot; + + @Autowired + private DiagnosticDocumentRepo diagnosticDocumentRepo; + + @Autowired + private CryptoUtil cryptoUtil; + + @Override + public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String externalOrderId, + DiagnosticDocumentAsset asset) throws Exception { + if (asset == null || asset.getBase64Content() == null) { + logger.warn("Skipping document ingest for benRegID={}, orderType={}: empty asset content", + benRegID, orderType); + return; + } + + byte[] originalBytes = Base64.getDecoder().decode(asset.getBase64Content()); + String sha256Hash = sha256Hex(originalBytes); + + DiagnosticDocumentType documentType = DiagnosticDocumentType.from(orderType, asset.getType()); + Optional existing = + diagnosticDocumentRepo.findByBenRegIDAndDocumentTypeAndDeletedFalse(benRegID, documentType.name()); + if (existing.isPresent() && sha256Hash.equals(existing.get().getSha256Hash())) { + logger.info("Diagnostic document already stored, skipping duplicate: benRegID={}, documentType={}", + benRegID, documentType); + return; + } + + long epochTime = Instant.now().toEpochMilli(); + String storedFileName = documentType.name() + ".enc"; + String relativeDir = String.valueOf(benRegID); + + // Documents must never touch disk unencrypted: encrypt in memory first, write only ciphertext. + String base64Original = Base64.getEncoder().encodeToString(originalBytes); + String encryptedPayload = cryptoUtil.encrypt(base64Original); + + Path dir = Paths.get(storageRoot, relativeDir); + Files.createDirectories(dir); + Path filePath = dir.resolve(storedFileName); + Files.write(filePath, encryptedPayload.getBytes(StandardCharsets.UTF_8)); + + DiagnosticDocument document = existing.orElseGet(DiagnosticDocument::new); + document.setDiagnosticOrderId(diagnosticOrderId); + document.setBenRegID(benRegID); + document.setOrderType(orderType); + document.setAssetType(asset.getType()); + document.setDocumentType(documentType.name()); + document.setEpochTime(epochTime); + document.setStoredFileName(storedFileName); + document.setStoredPath(relativeDir + "/" + storedFileName); + document.setSha256Hash(sha256Hash); + document.setContentType(asset.getContentType() != null ? asset.getContentType() : DEFAULT_CONTENT_TYPE); + document.setOriginalFileName(asset.getFileName()); + document.setCreatedBy("SYSTEM"); + try { + diagnosticDocumentRepo.save(document); + } catch (DataIntegrityViolationException dive) { + logger.warn("Lost document upsert race for benRegID={}, documentType={}", benRegID, documentType); + return; + } + + logger.info("Diagnostic document ingested: benRegID={}, orderType={}, epochTime={}", + benRegID, orderType, epochTime); + } + + @Override + public DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType) throws Exception { + DiagnosticDocument document = diagnosticDocumentRepo + .findByBenRegIDAndDocumentTypeAndDeletedFalse(benRegID, documentType.name()) + .orElseThrow(() -> new Exception( + "No document found for benRegID=" + benRegID + ", documentType=" + documentType)); + + Path filePath = Paths.get(storageRoot, document.getStoredPath()); + String encryptedPayload = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); + + String base64Original = cryptoUtil.decrypt(encryptedPayload); + if (base64Original == null) { + throw new Exception("Failed to decrypt document for benRegID=" + benRegID + ", documentType=" + documentType); + } + byte[] originalBytes = Base64.getDecoder().decode(base64Original); + + String recomputedHash = sha256Hex(originalBytes); + if (!recomputedHash.equals(document.getSha256Hash())) { + throw new Exception( + "Document integrity check failed for benRegID=" + benRegID + ", documentType=" + documentType); + } + + logger.info("Diagnostic document fetched: benRegID={}, documentType={}, epochTime={}", + benRegID, documentType, document.getEpochTime()); + + return new DiagnosticDocumentContent(originalBytes, document.getContentType()); + } + + private static String sha256Hex(byte[] data) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(String.format(Locale.ROOT, "%02x", b)); + } + return hex.toString(); + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java new file mode 100644 index 00000000..5ad13b59 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -0,0 +1,276 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.domain.iemr.DiagnosticResult; +import com.iemr.flw.dto.DiagnosticOrderRequestDto; +import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.integration.provider.DiagnosticProvider; +import com.iemr.flw.integration.provider.DiagnosticProviderFactory; +import com.iemr.flw.integration.provider.DiagnosticPushResult; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.repo.iemr.DiagnosticResultRepo; +import com.iemr.flw.service.DiagnosticDocumentService; +import com.iemr.flw.service.DiagnosticOrderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; + +@Service +public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticOrderServiceImpl.class); + + @Value("${diagnostic.active-provider}") + private String activeProvider; + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private DiagnosticResultRepo diagnosticResultRepo; + + @Autowired + private DiagnosticProviderFactory providerFactory; + + @Autowired + private DiagnosticDocumentService diagnosticDocumentService; + + @Override + public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { + Long benRegID = request.getBenRegID(); + Long visitCode = request.getVisitCode(); + DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); + String orderEvent = request.getOrderEvent(); + String patientFirstName = request.getPatient().getFirstName(); + String patientLastName = request.getPatient().getLastName(); + String patientDateOfBirth = request.getPatient().getDateOfBirth(); + String patientSex = request.getPatient().getSex(); + + String providerCode = activeProvider; + String externalOrderId = String.format("%d-%d-%s", benRegID, visitCode, orderType.name()); + + Optional existing = + diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType.name()); + + if (existing.isPresent() && !DiagnosticOrderStatus.FAILED.name().equals(existing.get().getStatus())) { + return existing.get(); + } + + DiagnosticOrder order = existing.orElseGet(DiagnosticOrder::new); + order.setOrderEvent(orderEvent); + order.setBenRegID(benRegID); + order.setVisitCode(visitCode); + order.setProviderServiceName(providerCode); + order.setProviderCode(providerCode); + order.setOrderType(orderType.name()); + order.setExternalOrderId(externalOrderId); + // Reset required when reusing a previously-FAILED row, otherwise a successful retry would + // leave status=FAILED and findDueForPoll (PENDING/IN_PROGRESS only) would never poll it. + order.setStatus(DiagnosticOrderStatus.PENDING.name()); + order.setErrorMessage(null); + order.setPatientFirstName(patientFirstName); + order.setPatientLastName(patientLastName); + order.setPatientDateOfBirth(patientDateOfBirth); + order.setPatientSex(patientSex); + try { + order = diagnosticOrderRepo.save(order); + } catch (DataIntegrityViolationException dive) { + Optional winner = diagnosticOrderRepo + .findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType.name()); + if (winner.isPresent()) { + logger.warn("Lost create race for benRegID={}, visitCode={}, orderType={} — returning existing order id={}", + benRegID, visitCode, orderType, winner.get().getId()); + return winner.get(); + } + throw dive; + } + + try { + DiagnosticProvider provider = providerFactory.getProvider(providerCode); + DiagnosticPushResult pushResult = provider.pushOrder(order); + order.setPushResponseJson(pushResult.getRawResponseJson()); + if (pushResult.isSuccess()) { + order.setProviderOrderId(pushResult.getProviderOrderId()); + } else { + order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setErrorMessage(pushResult.getErrorMessage()); + } + } catch (Exception e) { + logger.error("Failed to push order to provider, orderId={}: {}", order.getId(), e.getMessage()); + order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setErrorMessage(e.getMessage()); + } + order = diagnosticOrderRepo.save(order); + + return order; + } + + @Override + public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { + Optional existingResult = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()); + DiagnosticResult result = existingResult.orElseGet(DiagnosticResult::new); + result.setDiagnosticOrderId(order.getId()); + result.setBenRegID(order.getBenRegID()); + result.setProviderStatus(pollResult.getStatus().name()); + result.setResultSummary(pollResult.getResultSummary()); + result.setRawResponseJson(pollResult.getRawResponseJson()); + result.setTbPresence(pollResult.getTbPresence()); + result.setTbConfidence(pollResult.getTbConfidence()); + result.setDrugResistancePresence(pollResult.getDrugResistancePresence()); + result.setCreatedBy("SYSTEM"); + try { + diagnosticResultRepo.save(result); + } catch (DataIntegrityViolationException dive) { + logger.warn("Lost result upsert race for diagnosticOrderId={}", order.getId()); + result = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()).orElse(result); + } + + if (pollResult.getAssets() != null) { + for (DiagnosticDocumentAsset asset : pollResult.getAssets()) { + try { + diagnosticDocumentService.ingestAsset(order.getId(), order.getBenRegID(), order.getOrderType(), + order.getExternalOrderId(), asset); + } catch (Exception e) { + logger.error("Failed to ingest document asset for orderId={}, assetType={}, fileName={}: {}", + order.getId(), asset.getType(), asset.getFileName(), e.getMessage()); + } + } + } + + order.setStatus(pollResult.getStatus().name()); + order.setErrorMessage(pollResult.getErrorMessage()); + if (pollResult.getProviderOrderId() != null) { + order.setProviderOrderId(pollResult.getProviderOrderId()); + } + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + diagnosticOrderRepo.save(order); + + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setExternalOrderId(order.getExternalOrderId()); + dto.setOrderType(order.getOrderType()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + dto.setProviderStatus(result.getProviderStatus()); + dto.setResultSummary(result.getResultSummary()); + dto.setTbPresence(result.getTbPresence()); + dto.setTbConfidence(result.getTbConfidence()); + dto.setDrugResistancePresence(result.getDrugResistancePresence()); + return dto; + } + + @Override + public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { + DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); + DiagnosticPollResult result = provider.pollResult(order, false); + if (DiagnosticOrderStatus.COMPLETED.equals(result.getStatus())) { + result = provider.pollResult(order, true); + } + return result; + } + + private DiagnosticOrder findLatestOrder(Long benRegID, String orderType) throws Exception { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + return diagnosticOrderRepo + .findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, type.name()) + .orElseThrow(() -> new Exception( + "DiagnosticOrder not found for benRegID=" + benRegID + ", orderType=" + orderType)); + } + + @Override + public DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType) throws Exception { + DiagnosticOrder order = findLatestOrder(benRegID, orderType); + DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); + DiagnosticPollResult pollResult = provider.pollResult(order, true); + return processResult(order, pollResult); + } + + @Override + public DiagnosticOrder markTestCompleted(Long benRegID, String orderType) throws Exception { + DiagnosticOrder order = findLatestOrder(benRegID, orderType); + String status = order.getStatus(); + + if (order.getTestCompletedAt() != null) { + Optional existingResult = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()); + boolean isTerminal = DiagnosticOrderStatus.COMPLETED.name().equals(status) + || DiagnosticOrderStatus.FAILED.name().equals(status); + if (existingResult.isPresent() && isTerminal) { + // Re-test: the order already completed once and has a result on file, and the + // physical test was performed again — reopen it so the scheduler picks it back up + // and polls for the new result instead of leaving the stale one in place forever. + // Reset to PENDING (not IN_PROGRESS) so it mirrors a freshly-pushed order's + // starting state rather than looking like it's already mid-poll. + order.setTestCompletedAt(new Timestamp(System.currentTimeMillis())); + order.setStatus(DiagnosticOrderStatus.PENDING.name()); + order.setErrorMessage(null); + DiagnosticOrder savedOrder = diagnosticOrderRepo.save(order); + + // The stale result's own providerStatus also needs resetting - otherwise it keeps + // reporting the previous test's COMPLETED/FAILED status while the order itself + // shows PENDING, which is an inconsistent picture until the next poll overwrites it. + DiagnosticResult result = existingResult.get(); + result.setProviderStatus(DiagnosticOrderStatus.PENDING.name()); + diagnosticResultRepo.save(result); + + return savedOrder; + } + return order; // idempotent — already flagged, no result yet, don't reset the poll clock + } + + if (DiagnosticOrderStatus.COMPLETED.name().equals(status) + || DiagnosticOrderStatus.FAILED.name().equals(status) + || DiagnosticOrderStatus.CANCELLED.name().equals(status)) { + throw new IllegalStateException("Cannot mark test completed for order in terminal status " + status); + } + order.setTestCompletedAt(new Timestamp(System.currentTimeMillis())); + order.setStatus(DiagnosticOrderStatus.IN_PROGRESS.name()); + return diagnosticOrderRepo.save(order); + } + + @Override + public DiagnosticOrder getOrder(Long benRegID, String orderType) throws Exception { + return findLatestOrder(benRegID, orderType); + } + + @Override + public List getOrdersByBenRegId(Long benRegID) throws Exception { + return diagnosticOrderRepo.findByBenRegID(benRegID); + } + + @Override + public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setOrderType(orderType); + + Optional orderOpt = + diagnosticOrderRepo.findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, orderType); + if (orderOpt.isEmpty()) { + dto.setStatus("NOT_FOUND"); + return dto; + } + + DiagnosticOrder order = orderOpt.get(); + dto.setExternalOrderId(order.getExternalOrderId()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + + diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()).ifPresent(result -> { + dto.setProviderStatus(result.getProviderStatus()); + dto.setResultSummary(result.getResultSummary()); + dto.setTbPresence(result.getTbPresence()); + dto.setTbConfidence(result.getTbConfidence()); + dto.setDrugResistancePresence(result.getDrugResistancePresence()); + }); + return dto; + } +} diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java new file mode 100644 index 00000000..8fed25a7 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -0,0 +1,115 @@ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticPollResult; +import com.iemr.flw.masterEnum.DiagnosticOrderStatus; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; +import com.iemr.flw.service.DiagnosticOrderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +// Two independent schedulers, one per order family: XRAY_CHEST polls immediately on its own +// cadence, while TrueNat waits truenatInitialDelayMinutes after test completion before polling +// on its own (slower) cadence. +@Service +public class DiagnosticPollSchedulerService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticPollSchedulerService.class); + + @Value("${diagnostic.poll.give-up-minutes}") + private int giveUpMinutes; + + @Value("${diagnostic.poll.truenat.initial-delay-minutes}") + private int truenatInitialDelayMinutes; + + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + + @Autowired + private DiagnosticOrderService diagnosticOrderService; + + @Scheduled(fixedDelayString = "${diagnostic.poll.xray.tick-ms:15000}") + public void pollXrayOrders() { + List candidates = diagnosticOrderRepo.findXrayDueForPoll(); + logger.info("XRAY poll tick: {} order(s) in due-for-poll queue", candidates.size()); + if (candidates.isEmpty()) return; + + Instant now = Instant.now(); + int polled = 0; + for (DiagnosticOrder order : candidates) { + Instant testCompletedAt = order.getTestCompletedAt().toInstant(); + if (isExpired(testCompletedAt, now)) { + giveUp(order); + } else { + pollSingle(order); + polled++; + } + } + if (polled > 0) { + logger.info("Polled {} pending XRAY diagnostic orders", polled); + } + } + + @Scheduled(fixedDelayString = "${diagnostic.poll.truenat.tick-ms:60000}") + public void pollTrueNatOrders() { + List candidates = diagnosticOrderRepo.findTrueNatDueForPoll(); + logger.info("TrueNat poll tick: {} order(s) in due-for-poll queue", candidates.size()); + if (candidates.isEmpty()) return; + + Instant now = Instant.now(); + int polled = 0; + for (DiagnosticOrder order : candidates) { + Instant pollingStartedAt = order.getTestCompletedAt().toInstant() + .plus(truenatInitialDelayMinutes, ChronoUnit.MINUTES); + if (pollingStartedAt.isAfter(now)) { + continue; + } + if (isExpired(pollingStartedAt, now)) { + giveUp(order); + } else { + pollSingle(order); + polled++; + } + } + if (polled > 0) { + logger.info("Polled {} pending TrueNat diagnostic orders", polled); + } + } + + // Measured from when polling actually starts for this order (immediately for X-ray, after the + // initial delay for TrueNat), so one give-up-minutes value works for both families. + private boolean isExpired(Instant pollingStartedAt, Instant now) { + Instant deadline = pollingStartedAt.plus(giveUpMinutes, ChronoUnit.MINUTES); + return !deadline.isAfter(now); + } + + private void giveUp(DiagnosticOrder order) { + order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setErrorMessage("Polling window exceeded (" + giveUpMinutes + " min) without a result"); + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + diagnosticOrderRepo.save(order); + logger.warn("Order {} exceeded give-up window of {} min, marked FAILED", order.getId(), giveUpMinutes); + } + + private void pollSingle(DiagnosticOrder order) { + try { + DiagnosticPollResult result = diagnosticOrderService.pollOnce(order); + diagnosticOrderService.processResult(order, result); + } catch (Exception e) { + logger.error("Poll failed for orderId={}: {}", order.getId(), e.getMessage()); + order.setRetryCount(order.getRetryCount() + 1); + order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setErrorMessage(e.getMessage()); + diagnosticOrderRepo.save(order); + } + } +} From c940bfc267ef28a351122b0f44b5712cf1ae94f5 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Sun, 19 Jul 2026 19:04:28 +0530 Subject: [PATCH 41/47] Added api for listing of beneficiaries id according to diagnostic order status --- .../controller/DiagnosticOrderController.java | 16 +++++++++++ .../dto/DiagnosticOrderStatusSummaryDto.java | 16 +++++++++++ .../flw/repo/iemr/DiagnosticOrderRepo.java | 27 +++++++++++++++++++ .../flw/service/DiagnosticOrderService.java | 3 +++ .../impl/DiagnosticOrderServiceImpl.java | 14 ++++++++++ 5 files changed, 76 insertions(+) create mode 100644 src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java index bcc50d5c..917183d7 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -5,12 +5,16 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; +import com.iemr.flw.masterEnum.DiagnosticOrderType; import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.utils.ApiResponse; import com.iemr.flw.utils.response.OutputResponse; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -110,4 +114,16 @@ public String pollOrder(@RequestParam Long benRegID, @RequestParam String orderT } return response.toString(); } + + @GetMapping("/order/getBeneficiariesByStatus") + @Operation(summary = "Get beneficiary IDs bucketed by diagnostic order status for the given order type, " + + "optionally filtered by village and/or provider service map") + public ResponseEntity getBeneficiariesByStatus( + @RequestParam DiagnosticOrderType orderType, + @RequestParam(required = false) Integer villageId, + @RequestParam(required = false) Integer providerServiceMapId) { + DiagnosticOrderStatusSummaryDto result = + diagnosticOrderService.getOrderStatusSummary(orderType.name(), villageId, providerServiceMapId); + return ResponseEntity.ok(new ApiResponse(true, "Diagnostic order status summary fetched successfully", result)); + } } diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java new file mode 100644 index 00000000..965c373b --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -0,0 +1,16 @@ +package com.iemr.flw.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticOrderStatusSummaryDto { + private List awaitingTestCompletion; + private List awaitingProviderResult; + private List completed; +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index 7a892877..ea9bcefc 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -44,4 +44,31 @@ Optional findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByC @Modifying @Query("UPDATE DiagnosticOrder o SET o.vanSerialNo = o.id WHERE o.id = :id") void updateVanSerialNo(@Param("id") Long id); + + // Beneficiary-status-summary queries, filtered the same way as + // FormResponseRepo.findBeneficiaryIdsByFormIdAndStatusFiltered - null-safe optional + // villageId/providerServiceMapId via a correlated BenFlowStatus subquery. + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.testCompletedAt IS NULL " + + "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsAwaitingTestCompletion(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.testCompletedAt IS NOT NULL AND o.status <> 'COMPLETED' " + + "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsAwaitingProviderResult(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.status = 'COMPLETED' " + + "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsCompleted(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); } diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java index 24c18525..f3ce6276 100644 --- a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -3,6 +3,7 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; import com.iemr.flw.integration.provider.DiagnosticPollResult; import java.util.List; @@ -24,4 +25,6 @@ public interface DiagnosticOrderService { DiagnosticOrder getOrder(Long benRegID, String orderType) throws Exception; List getOrdersByBenRegId(Long benRegID) throws Exception; + + DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, Integer providerServiceMapId); } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 5ad13b59..372cfe62 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -4,6 +4,7 @@ import com.iemr.flw.domain.iemr.DiagnosticResult; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -273,4 +274,17 @@ public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType) }); return dto; } + + @Override + public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, + Integer providerServiceMapId) { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + List awaitingTestCompletion = diagnosticOrderRepo + .findBenRegIDsAwaitingTestCompletion(type.name(), villageId, providerServiceMapId); + List awaitingProviderResult = diagnosticOrderRepo + .findBenRegIDsAwaitingProviderResult(type.name(), villageId, providerServiceMapId); + List completed = diagnosticOrderRepo + .findBenRegIDsCompleted(type.name(), villageId, providerServiceMapId); + return new DiagnosticOrderStatusSummaryDto(awaitingTestCompletion, awaitingProviderResult, completed); + } } From b335706a5e922173d54a33af26fd593cc03e3d75 Mon Sep 17 00:00:00 2001 From: sehjotsinghunthinkable Date: Mon, 20 Jul 2026 12:24:52 +0530 Subject: [PATCH 42/47] Feature/device integration (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enforced mandatory validation for "Reason for Refusal in section e in file TbCounsellingFormSeeder * Added counselled flag to confirmed cases response, indicating whether a beneficiary has completed the TB Counselling form. Implemented using a single bulk query to avoid N+1 lookups against FormResponse. * Align `/complete` and `/submitBulk` form response editing behavior * Updated `/complete` to resolve `FormResponse` using `beneficiaryId` and `formUuid` from the request body, removing the dependency on `responseId` as a query parameter. * Updated `/submitBulk` section editing to match `/complete` behavior by performing in-place `SectionResponse` upserts and per-question delete-and-reinsert operations, instead of deleting and recreating the entire section. * Add van/sync tracking fields (vanID, parkingPlaceID, vanSerialNo, processed, syncedDate, syncedBy, syncFailureReason) to domain entities for offline mobile data collection support * feat(dynamic-form): add Hindi localization fields and completed beneficiaries endpoint - Added `sectionName_hindi`, `questionText_hindi`, and `optionValue_hindi` fields to the FormSection, SectionQuestion, and QuestionOption entities to support Hindi localization. - Added `GET /dynamicForm/response/getCompletedBeneficiaries?formType=` endpoint to return beneficiary IDs with `COMPLETE` status for the specified form type. * feat(dynamic-form): add Hindi localization fields and completed beneficiaries endpoint - Added `sectionName_hindi`, `questionText_hindi`, and `optionValue_hindi` fields to the FormSection, SectionQuestion, and QuestionOption entities to support Hindi localization. - Added `GET /dynamicForm/response/getCompletedBeneficiaries?formType=` endpoint to return beneficiary IDs with `COMPLETE` status for the specified form type. * feat(beneficiary): expose GPS coordinates in getBeneficiaryData response Add gpsLatitude, gpsLongitude, digipin, gpsTimestamp, and isGpsUnavailable fields to RMNCHBeneficiaryDetailsRmnch and RMNCHHouseHoldDetails entities, mapping to the columns written by Identity-API's /syncDataToAmrit endpoint. Enable serializeNulls on the Gson builder in BeneficiaryServiceImpl so GPS keys are always present in householdDetails and beneficiaryDetails — null for web-registered records, real values for mobile-registered ones. Co-Authored-By: Claude Sonnet 4.6 * Committed 82b00ccf with only the three files: - BeneficiaryServiceImpl.java — serializeNulls() + GPS fallback from address - RMNCHBeneficiaryDetailsRmnch.java — gpsLatitude, gpsLongitude, digipin, gpsTimestamp, isGpsUnavailable fields - RMNCHHouseHoldDetails.java — gpsLatitude, gpsLongitude fields * Revert "Committed 82b00ccf with only the three files:" This reverts commit 3040a08077d82d27dfd86f29f0fbdd4dc094ec56. * Committed 82b00ccf with only the three files: - BeneficiaryServiceImpl.java — serializeNulls() + GPS fallback from address - RMNCHBeneficiaryDetailsRmnch.java — gpsLatitude, gpsLongitude, digipin, gpsTimestamp, isGpsUnavailable fields - RMNCHHouseHoldDetails.java — gpsLatitude, gpsLongitude fields * Committed 82b00ccf with only the three files: - BeneficiaryServiceImpl.java — serializeNulls() + GPS fallback from address - RMNCHBeneficiaryDetailsRmnch.java — gpsLatitude, gpsLongitude, digipin, gpsTimestamp, isGpsUnavailable fields - RMNCHHouseHoldDetails.java — gpsLatitude, gpsLongitude fields # Conflicts: # src/main/java/com/iemr/flw/domain/identity/RMNCHMBeneficiaryaddress.java * Updated isMandatory field in Section C, so changed in TbCounsellingFormSeeder * Add optional villageId and providerServiceMapId filters to getCompletedBeneficiaries * Removing section f from seeder * Removing already existing check in /complete api * Add a CHECKBOX question type and a TB_COUNSELLING_V2 seeder with a GENERAL_INFO consent gate ahead of the counselling sections. * Seed single-checkbox questions for Sections A-D and point the Counselled lookup at TB_COUNSELLING_V2 instead of the retired V1 form * Mark form/section status REFUSED when TB2_GI_Q1=NO on /complete, split getCompletedBeneficiaries into completed/refused lists, and extract FormResponseStatus enum * Resolved conflicts * fix(stoptb): getBeneficiaryData reads anthropometry/vitals from standard tables - getBeneficiaryData now reads height/weight/bmi from t_phy_anthropometry and temperature/pulse/BP/RBS from t_phy_vitals (latest row per beneficiary) - Fallback to otherFields if exam not yet saved for the beneficiary - Stop TB specific fields (personFrom, caseFindingType etc.) still from otherFields - Add findByBeneficiaryRegIDOrderByCreatedDateDesc and findByBenVisitID to BenAnthropometryRepo and BenPhysicalVitalRepo - Add StopTB_Future_Roadmap.md for future HWC integration and edit support Co-Authored-By: Claude Sonnet 4.6 * fix(compile): rename variable 'a' to 'anthro' to avoid conflict with outer loop variable Co-Authored-By: Claude Sonnet 4.6 * fix(stoptb): set CreatedDate on t_benvisitdetail insert to prevent NOT NULL constraint violation Co-Authored-By: Claude Sonnet 4.6 * fix(anc-pnc): guard CreatedDate on BenVisitDetail insert for ANC and PNC flows If mobile does not send createdDate, ModelMapper leaves it null and MySQL rejects the insert with NOT NULL constraint. Set server timestamp as fallback. Co-Authored-By: Claude Sonnet 4.6 * fix(docker): set TZ=Asia/Kolkata to prevent UTC/IST date mismatch Without explicit timezone, Docker defaults to UTC. This causes LocalDate.now() in getOrCreateVisitForToday() to use UTC date, so evening IST registrations appear as next-day July 1 in DB and visit lookups fail after UTC midnight. Co-Authored-By: Claude Sonnet 4.6 * fix(stoptb): handle ISO 8601 Z suffix in parseDob to fix beneficiary age display Timestamp.valueOf() throws on "yyyy-MM-dd HH:mm:ss.SSSZ" format because the trailing Z is not a valid nanosecond value. Strip Z before parsing so mobile DOB like "1987-07-01T00:00:00.000Z" is correctly stored in i_ben_flow_outreach.ben_dob. Co-Authored-By: Claude Sonnet 4.6 * fix(stoptb): fallback to i_ben_flow_outreach.ben_dob when i_beneficiarydetails.dob is null Identity-API mapper (@Mapping source=dto.dob) is commented out, causing dob to be null for all mobile registrations. Fall back to ben_dob from i_ben_flow_outreach so getBeneficiaryData returns correct DOB and mobile shows correct age. Co-Authored-By: Claude Sonnet 4.6 * fix: use saveAndFlush to prevent FK violation in t_benchiefcomplaint save() leaves VisitCode UPDATE pending in JPA session — t_benchiefcomplaint INSERT runs before the UPDATE reaches DB, causing FK constraint failure and full transaction rollback. saveAndFlush forces the UPDATE to DB immediately. Co-Authored-By: Claude Sonnet 4.6 * fix(stoptb): compute age from benDetailsRMNCH_OBJ.dob instead of benDetailsOBJ.dob benDetailsOBJ.dob (i_beneficiarydetails) is null for Stop TB mobile registrations, which is why c22b3fd2 added a fallback that fills benDetailsRMNCH_OBJ.dob from i_ben_flow_outreach. The age calculation block still read the original, still-null benDetailsOBJ.dob, so dob came back correct but age/ageFull stayed 0/"". Point it at the already-corrected benDetailsRMNCH_OBJ.dob instead. Co-Authored-By: Claude Sonnet 5 * fix(stoptb): scope Suspected/Confirmed getAll by facility+village instead of userId Nurse and Counsellor are different logins hitting the same /tb/suspected/getAll and /tb/confirmed/getAll endpoints, but both queries filtered by the calling user's own userId. So a Counsellor pulling data after a Nurse creates a Suspected/Confirmed record saw none of it - only rows tied to their own login. Adds an optional providerServiceMapID(+villageID)-scoped query path on both endpoints, joined through the already-populated BenFlowStatus table (same pattern as StopTBDiagnosticsRepo), so any role at the same facility/village sees the same worklist. The old userId-based path is kept as the fallback when the new fields aren't sent, so already-deployed app builds are unaffected. * fix(stoptb): drop date filter from facility-scoped Suspected query visitDate isn't the right dimension for a facility worklist - an unresolved Suspected case from months ago is still actionable, and filtering it out by date would hide it from the Counsellor, working against the visibility fix in the previous commit. Also matches StopTBDiagnosticsRepo's facility-scoped query, which never date-filters. Old ashaId-based fallback path is untouched and still date-filters as before. * fix(stoptb): join facility-scoped query on beneficiaryID, not beneficiaryRegID TBSuspected.benId / TBConfirmedCase.benId store the person-level beneficiary_id (e.g. 907797933607), not beneficiaryRegID (e.g. 35137) - confirmed by querying i_ben_flow_outreach directly. The facility-scoped query added in the previous two commits joined against BenFlowStatus.beneficiaryRegID, so it never matched anything and always returned empty. Switched the join to BenFlowStatus.beneficiaryID, which is the correct corresponding column. * Added counselled flag to confirmed cases response, indicating whether a beneficiary has completed the TB Counselling form. Implemented using a single bulk query to avoid N+1 lookups against FormResponse. * Revert "Committed 82b00ccf with only the three files:" This reverts commit 3040a08077d82d27dfd86f29f0fbdd4dc094ec56. * Committed 82b00ccf with only the three files: - BeneficiaryServiceImpl.java — serializeNulls() + GPS fallback from address - RMNCHBeneficiaryDetailsRmnch.java — gpsLatitude, gpsLongitude, digipin, gpsTimestamp, isGpsUnavailable fields - RMNCHHouseHoldDetails.java — gpsLatitude, gpsLongitude fields * Integrated diagnostic device (X-ray/CAD/Trunat) results and document storage with the diagnostic order workflow * ● Add TrueNat TB/drug-resistance classification, split XRAY/TrueNat pollers, fix document-type collisions, and harden EMRLITE token/error handling * Making changes in common_example.properties * Added api for listing of beneficiaries id according to diagnostic order status --------- Co-authored-by: Vishwanath Balkur <118195001+vishwab1@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: vishwab1 --- .../controller/DiagnosticOrderController.java | 16 +++++++++++ .../dto/DiagnosticOrderStatusSummaryDto.java | 16 +++++++++++ .../flw/repo/iemr/DiagnosticOrderRepo.java | 27 +++++++++++++++++++ .../flw/service/DiagnosticOrderService.java | 3 +++ .../impl/DiagnosticOrderServiceImpl.java | 14 ++++++++++ 5 files changed, 76 insertions(+) create mode 100644 src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java index bcc50d5c..917183d7 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -5,12 +5,16 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; +import com.iemr.flw.masterEnum.DiagnosticOrderType; import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.utils.ApiResponse; import com.iemr.flw.utils.response.OutputResponse; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -110,4 +114,16 @@ public String pollOrder(@RequestParam Long benRegID, @RequestParam String orderT } return response.toString(); } + + @GetMapping("/order/getBeneficiariesByStatus") + @Operation(summary = "Get beneficiary IDs bucketed by diagnostic order status for the given order type, " + + "optionally filtered by village and/or provider service map") + public ResponseEntity getBeneficiariesByStatus( + @RequestParam DiagnosticOrderType orderType, + @RequestParam(required = false) Integer villageId, + @RequestParam(required = false) Integer providerServiceMapId) { + DiagnosticOrderStatusSummaryDto result = + diagnosticOrderService.getOrderStatusSummary(orderType.name(), villageId, providerServiceMapId); + return ResponseEntity.ok(new ApiResponse(true, "Diagnostic order status summary fetched successfully", result)); + } } diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java new file mode 100644 index 00000000..965c373b --- /dev/null +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -0,0 +1,16 @@ +package com.iemr.flw.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticOrderStatusSummaryDto { + private List awaitingTestCompletion; + private List awaitingProviderResult; + private List completed; +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index 7a892877..ea9bcefc 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -44,4 +44,31 @@ Optional findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByC @Modifying @Query("UPDATE DiagnosticOrder o SET o.vanSerialNo = o.id WHERE o.id = :id") void updateVanSerialNo(@Param("id") Long id); + + // Beneficiary-status-summary queries, filtered the same way as + // FormResponseRepo.findBeneficiaryIdsByFormIdAndStatusFiltered - null-safe optional + // villageId/providerServiceMapId via a correlated BenFlowStatus subquery. + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.testCompletedAt IS NULL " + + "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsAwaitingTestCompletion(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.testCompletedAt IS NOT NULL AND o.status <> 'COMPLETED' " + + "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsAwaitingProviderResult(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.status = 'COMPLETED' " + + "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsCompleted(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); } diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java index 24c18525..f3ce6276 100644 --- a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -3,6 +3,7 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; import com.iemr.flw.integration.provider.DiagnosticPollResult; import java.util.List; @@ -24,4 +25,6 @@ public interface DiagnosticOrderService { DiagnosticOrder getOrder(Long benRegID, String orderType) throws Exception; List getOrdersByBenRegId(Long benRegID) throws Exception; + + DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, Integer providerServiceMapId); } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 5ad13b59..372cfe62 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -4,6 +4,7 @@ import com.iemr.flw.domain.iemr.DiagnosticResult; import com.iemr.flw.dto.DiagnosticOrderRequestDto; import com.iemr.flw.dto.DiagnosticOrderResultDto; +import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -273,4 +274,17 @@ public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType) }); return dto; } + + @Override + public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, + Integer providerServiceMapId) { + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + List awaitingTestCompletion = diagnosticOrderRepo + .findBenRegIDsAwaitingTestCompletion(type.name(), villageId, providerServiceMapId); + List awaitingProviderResult = diagnosticOrderRepo + .findBenRegIDsAwaitingProviderResult(type.name(), villageId, providerServiceMapId); + List completed = diagnosticOrderRepo + .findBenRegIDsCompleted(type.name(), villageId, providerServiceMapId); + return new DiagnosticOrderStatusSummaryDto(awaitingTestCompletion, awaitingProviderResult, completed); + } } From 1b29666b4120064fc0fc5d05fa978f1230c5dbe7 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Mon, 20 Jul 2026 15:49:13 +0530 Subject: [PATCH 43/47] feat(household): map totalHhMembers, registeredAtCampSite, registeredAtCampSiteId Columns already exist in i_householddetails but weren't mapped on FLW-API's local entity, so they were silently dropped from getBeneficiaryData responses even though Identity-API's entity already had them. Co-Authored-By: Claude Sonnet 5 --- .../flw/domain/identity/RMNCHHouseHoldDetails.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java index a0a0adf1..445c4d14 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java @@ -337,4 +337,16 @@ public class RMNCHHouseHoldDetails { @Column(name = "gpsLongitude") private Double gpsLongitude; + @Expose + @Column(name = "totalHhMembers") + private Integer totalHhMembers; + + @Expose + @Column(name = "registeredAtCampSite") + private String registeredAtCampSite; + + @Expose + @Column(name = "registeredAtCampSiteId") + private Integer registeredAtCampSiteId; + } From 9d2a328afa2fa665443583709d0f5cc52c57bdb3 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Mon, 20 Jul 2026 17:35:14 +0530 Subject: [PATCH 44/47] feat(household): surface beneficiary's address in getBeneficiaryData household block No household-level address column exists and none is needed - the beneficiary's own PermAddressValue is the source of truth. Added as a transient (non-persisted) field so the household object in the response carries it without any DB or Identity-API change. Co-Authored-By: Claude Sonnet 5 --- .../com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java | 4 ++++ .../com/iemr/flw/service/impl/BeneficiaryServiceImpl.java | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java index 445c4d14..ce3ddb7f 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java @@ -337,6 +337,10 @@ public class RMNCHHouseHoldDetails { @Column(name = "gpsLongitude") private Double gpsLongitude; + @Expose + @Transient + private String address; + @Expose @Column(name = "totalHhMembers") private Integer totalHhMembers; diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index aef14aaf..02807ea3 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -518,9 +518,11 @@ private String getMappingsForAddressIDs(List addressLi benDetailsRMNCH_OBJ.setAge_unit(ageUnit); resultMap = new HashMap<>(); - if (benHouseHoldRMNCH_ROBJ != null) + if (benHouseHoldRMNCH_ROBJ != null) { + if (benAddressOBJ.getPermAddressValue() != null) + benHouseHoldRMNCH_ROBJ.setAddress(benAddressOBJ.getPermAddressValue()); resultMap.put("householdDetails", benHouseHoldRMNCH_ROBJ); - else + } else resultMap.put("householdDetails", new HashMap()); if (benBotnBirthRMNCH_ROBJ != null) From 54107fc46a30ec1988c2dc11b3f117118a465f6d Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Mon, 20 Jul 2026 17:44:22 +0530 Subject: [PATCH 45/47] fix(household): use permAddrLine1 for household address, not the unused permAddressValue field permAddressValue is a separate, never-populated column on i_beneficiaryaddress. The actual address data (visible in beneficiaryDetails.addressLine1) lives in permAddrLine1. Co-Authored-By: Claude Sonnet 5 --- .../com/iemr/flw/service/impl/BeneficiaryServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index 02807ea3..1e4d9ae1 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -519,8 +519,8 @@ private String getMappingsForAddressIDs(List addressLi resultMap = new HashMap<>(); if (benHouseHoldRMNCH_ROBJ != null) { - if (benAddressOBJ.getPermAddressValue() != null) - benHouseHoldRMNCH_ROBJ.setAddress(benAddressOBJ.getPermAddressValue()); + if (benAddressOBJ.getPermAddrLine1() != null) + benHouseHoldRMNCH_ROBJ.setAddress(benAddressOBJ.getPermAddrLine1()); resultMap.put("householdDetails", benHouseHoldRMNCH_ROBJ); } else resultMap.put("householdDetails", new HashMap()); From 50a72d4ffa362b6ae7044ddf307d08e8f2ead8e8 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Mon, 20 Jul 2026 17:54:49 +0530 Subject: [PATCH 46/47] feat(household): persist address as a real column instead of transient display-only field i_householddetails now has an address column (added in Identity-API). Map it as a real @Column here too, and only fall back to the beneficiary's own permAddrLine1 when the household record itself doesn't have an address yet (older records pre-dating this column). Co-Authored-By: Claude Sonnet 5 --- .../com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java | 2 +- .../java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java index ce3ddb7f..20cd8bfb 100644 --- a/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java +++ b/src/main/java/com/iemr/flw/domain/identity/RMNCHHouseHoldDetails.java @@ -338,7 +338,7 @@ public class RMNCHHouseHoldDetails { private Double gpsLongitude; @Expose - @Transient + @Column(name = "address") private String address; @Expose diff --git a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java index 1e4d9ae1..4934a3ef 100644 --- a/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/BeneficiaryServiceImpl.java @@ -519,7 +519,8 @@ private String getMappingsForAddressIDs(List addressLi resultMap = new HashMap<>(); if (benHouseHoldRMNCH_ROBJ != null) { - if (benAddressOBJ.getPermAddrLine1() != null) + if (benHouseHoldRMNCH_ROBJ.getAddress() == null + && benAddressOBJ.getPermAddrLine1() != null) benHouseHoldRMNCH_ROBJ.setAddress(benAddressOBJ.getPermAddrLine1()); resultMap.put("householdDetails", benHouseHoldRMNCH_ROBJ); } else From 5fea3ec335215a6ff95eea5e38843f60a089f10a Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 20 Jul 2026 19:49:36 +0530 Subject: [PATCH 47/47] Making changes for retest of a order and change the response for getBeneficiaryByStatus api --- .../DiagnosticDocumentController.java | 10 +++-- .../controller/DiagnosticOrderController.java | 32 +++++++++------ .../flw/domain/iemr/DiagnosticDocument.java | 2 +- .../dto/DiagnosticOrderStatusSummaryDto.java | 2 + .../provider/emrlite/EmrLiteProvider.java | 18 ++++++++- .../masterEnum/DiagnosticDocumentType.java | 17 ++++++++ .../flw/masterEnum/DiagnosticOrderStatus.java | 3 +- .../flw/repo/iemr/DiagnosticDocumentRepo.java | 2 +- .../flw/repo/iemr/DiagnosticOrderRepo.java | 37 +++++++++++++++-- .../service/DiagnosticDocumentService.java | 2 +- .../flw/service/DiagnosticOrderService.java | 8 ++-- .../impl/DiagnosticDocumentServiceImpl.java | 36 ++++++++++++----- .../impl/DiagnosticOrderServiceImpl.java | 40 ++++++++++++++----- .../impl/DiagnosticPollSchedulerService.java | 4 +- 14 files changed, 161 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java index b96442c1..c48e257f 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticDocumentController.java @@ -32,11 +32,13 @@ public DiagnosticDocumentController(DiagnosticDocumentService diagnosticDocument } @GetMapping - @Operation(summary = "Fetch the most recent stored document for a beneficiary + document type " - + "(XRAY_CHEST, XRAY_CHEST_ANNOTATED, CAD, MTB_REPORT, MTB_PLUS_REPORT, or MDR_RIF_REPORT)") - public ResponseEntity getDocument(@RequestParam Long benId, @RequestParam DiagnosticDocumentType documentType) { + @Operation(summary = "Fetch the stored document for a beneficiary + document type " + + "(XRAY_CHEST, XRAY_CHEST_ANNOTATED, CAD, MTB_REPORT, MTB_PLUS_REPORT, or MDR_RIF_REPORT). " + + "Defaults to the most recent order's document; pass visitCode to target a specific order/retest.") + public ResponseEntity getDocument(@RequestParam Long benId, @RequestParam DiagnosticDocumentType documentType, + @RequestParam(required = false) Long visitCode) { try { - DiagnosticDocumentContent content = diagnosticDocumentService.fetch(benId, documentType); + DiagnosticDocumentContent content = diagnosticDocumentService.fetch(benId, documentType, visitCode); MediaType mediaType; try { mediaType = MediaType.parseMediaType(content.getContentType()); diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java index 917183d7..e300a452 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -46,11 +46,13 @@ public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { } @PostMapping("/order/get") - @Operation(summary = "Get the diagnostic order for a beneficiary+orderType (latest, if more than one exists)") - public String getOrder(@RequestParam Long benRegID, @RequestParam String orderType) { + @Operation(summary = "Get the diagnostic order for a beneficiary+orderType (latest, if more than one exists; " + + "pass visitCode to target a specific order/retest)") + public String getOrder(@RequestParam Long benRegID, @RequestParam String orderType, + @RequestParam(required = false) Long visitCode) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrder order = diagnosticOrderService.getOrder(benRegID, orderType); + DiagnosticOrder order = diagnosticOrderService.getOrder(benRegID, orderType, visitCode); response.setResponse(new Gson().toJson(order)); } catch (Exception e) { logger.error("Error in getOrder: {}", e.getMessage()); @@ -74,11 +76,13 @@ public String getOrdersByBen(@PathVariable Long benRegID) { } @PostMapping("/order/testCompleted") - @Operation(summary = "Mark that the physical test has completed for this beneficiary+orderType, starting the poll cadence") - public String markTestCompleted(@RequestParam Long benRegID, @RequestParam String orderType) { + @Operation(summary = "Mark that the physical test has completed for this beneficiary+orderType, starting the poll cadence " + + "(targets the latest order unless visitCode is given)") + public String markTestCompleted(@RequestParam Long benRegID, @RequestParam String orderType, + @RequestParam(required = false) Long visitCode) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrder order = diagnosticOrderService.markTestCompleted(benRegID, orderType); + DiagnosticOrder order = diagnosticOrderService.markTestCompleted(benRegID, orderType, visitCode); response.setResponse(new Gson().toJson(order)); } catch (Exception e) { logger.error("Error in markTestCompleted: {}", e.getMessage()); @@ -88,11 +92,13 @@ public String markTestCompleted(@RequestParam Long benRegID, @RequestParam Strin } @PostMapping("/order/result") - @Operation(summary = "Get the diagnostic result for benId+orderType, at whatever stage it's currently in") - public String getOrderResult(@RequestParam Long benId, @RequestParam String orderType) { + @Operation(summary = "Get the diagnostic result for benId+orderType, at whatever stage it's currently in " + + "(targets the latest order unless visitCode is given)") + public String getOrderResult(@RequestParam Long benId, @RequestParam String orderType, + @RequestParam(required = false) Long visitCode) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrderResultDto result = diagnosticOrderService.getOrderResult(benId, orderType); + DiagnosticOrderResultDto result = diagnosticOrderService.getOrderResult(benId, orderType, visitCode); response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in getOrderResult: {}", e.getMessage()); @@ -102,11 +108,13 @@ public String getOrderResult(@RequestParam Long benId, @RequestParam String orde } @PostMapping("/order/poll") - @Operation(summary = "Trigger an immediate poll for one beneficiary+orderType's diagnostic order and return the result (ops use)") - public String pollOrder(@RequestParam Long benRegID, @RequestParam String orderType) { + @Operation(summary = "Trigger an immediate poll for one beneficiary+orderType's diagnostic order and return the result (ops use). " + + "Targets the latest order unless visitCode is given") + public String pollOrder(@RequestParam Long benRegID, @RequestParam String orderType, + @RequestParam(required = false) Long visitCode) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrderResultDto result = diagnosticOrderService.triggerManualPoll(benRegID, orderType); + DiagnosticOrderResultDto result = diagnosticOrderService.triggerManualPoll(benRegID, orderType, visitCode); response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in pollOrder: {}", e.getMessage()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java index c24e9422..cb7ce27b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticDocument.java @@ -9,7 +9,7 @@ @Entity @Table(name = "tb_diagnostic_document", schema = "db_iemr", - uniqueConstraints = @UniqueConstraint(columnNames = {"ben_reg_id", "document_type"}), + uniqueConstraints = @UniqueConstraint(columnNames = {"diagnostic_order_id", "document_type"}), indexes = { @Index(name = "idx_diagnostic_document_ben_reg_id", columnList = "ben_reg_id"), @Index(name = "idx_diagnostic_document_order_type", columnList = "order_type"), diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java index 965c373b..c1dd235e 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -13,4 +13,6 @@ public class DiagnosticOrderStatusSummaryDto { private List awaitingTestCompletion; private List awaitingProviderResult; private List completed; + private List pollingTimedOut; + private List failed; } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java index 2dc65eea..1c1c776d 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -39,7 +39,9 @@ public class EmrLiteProvider implements DiagnosticProvider { private static final String TUBERCULOSIS_FINDING_NAME = "Tuberculosis"; private static final String SUMMARY_TB_POSITIVE = "TB Positive"; + private static final String SUMMARY_TB_NEGATIVE = "TB Negative"; private static final String SUMMARY_DR_TB = "DR TB"; + private static final String SUMMARY_NON_DR_TB = "Non DR TB"; @Value("${diagnostic.emrlite.order-url}") private String orderUrl; @@ -139,18 +141,30 @@ public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAss private Boolean classifyTbPresenceFromSummary(String orderType, String summary) { if (!DiagnosticOrderType.MTB.name().equals(orderType) && !DiagnosticOrderType.MTB_PLUS.name().equals(orderType)) { + return null; // TB presence isn't tested for this orderType — not applicable, not a confirmed negative + } + if (SUMMARY_TB_POSITIVE.equalsIgnoreCase(summary)) { + return true; + } + if (SUMMARY_TB_NEGATIVE.equalsIgnoreCase(summary)) { return false; } - return SUMMARY_TB_POSITIVE.equalsIgnoreCase(summary); + return null; // Error/Indeterminate/anything else — genuinely unknown, not a confirmed negative } // MDR_RIF's summary reports rifampicin drug-resistance, not raw TB presence — kept as its own // field rather than folded into tbPresence. private Boolean classifyDrugResistanceFromSummary(String orderType, String summary) { if (!DiagnosticOrderType.MDR_RIF.name().equals(orderType)) { + return null; // Drug resistance isn't tested for this orderType — not applicable, not a confirmed negative + } + if (SUMMARY_DR_TB.equalsIgnoreCase(summary)) { + return true; + } + if (SUMMARY_NON_DR_TB.equalsIgnoreCase(summary)) { return false; } - return SUMMARY_DR_TB.equalsIgnoreCase(summary); + return null; // Error/Indeterminate/anything else — genuinely unknown, not a confirmed negative } private EmrLiteCadRawJson.FindingDto extractTuberculosisFinding(EmrLiteResultResponse result) { diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java index 5416df34..3e26e5e5 100644 --- a/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticDocumentType.java @@ -31,4 +31,21 @@ public static DiagnosticDocumentType from(String orderType, String assetType) { default: throw new IllegalArgumentException("Unknown orderType for document classification: " + orderType); } } + + public DiagnosticOrderType impliedOrderType() { + switch (this) { + case XRAY_CHEST: + case XRAY_CHEST_ANNOTATED: + case CAD: + return DiagnosticOrderType.XRAY_CHEST; + case MTB_REPORT: + return DiagnosticOrderType.MTB; + case MTB_PLUS_REPORT: + return DiagnosticOrderType.MTB_PLUS; + case MDR_RIF_REPORT: + return DiagnosticOrderType.MDR_RIF; + default: + throw new IllegalArgumentException("Unknown orderType for document type: " + this); + } + } } diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java index 89820c24..75cb0e5e 100644 --- a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -5,7 +5,8 @@ public enum DiagnosticOrderStatus { IN_PROGRESS, COMPLETED, FAILED, - CANCELLED; + CANCELLED, + EXPIRED; public static DiagnosticOrderStatus fromString(String value) { if (value == null) return PENDING; diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java index e97a188f..4679c400 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticDocumentRepo.java @@ -9,5 +9,5 @@ @Repository public interface DiagnosticDocumentRepo extends JpaRepository { - Optional findByBenRegIDAndDocumentTypeAndDeletedFalse(Long benRegID, String documentType); + Optional findByDiagnosticOrderIdAndDocumentTypeAndDeletedFalse(Long diagnosticOrderId, String documentType); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index ea9bcefc..5692da36 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -48,17 +48,24 @@ Optional findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByC // Beneficiary-status-summary queries, filtered the same way as // FormResponseRepo.findBeneficiaryIdsByFormIdAndStatusFiltered - null-safe optional // villageId/providerServiceMapId via a correlated BenFlowStatus subquery. + // Each also requires o.id to be the latest (max id) order for its benRegID+orderType, so a + // beneficiary with an old terminal order and a new in-flight retest of the same orderType + // is bucketed only by the retest, not both. @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + "AND o.testCompletedAt IS NULL " + - "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") List findBenRegIDsAwaitingTestCompletion(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.testCompletedAt IS NOT NULL AND o.status <> 'COMPLETED' " + - "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND o.testCompletedAt IS NOT NULL AND o.status NOT IN ('COMPLETED', 'EXPIRED', 'FAILED') " + + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") List findBenRegIDsAwaitingProviderResult(@Param("orderType") String orderType, @@ -66,9 +73,31 @@ List findBenRegIDsAwaitingProviderResult(@Param("orderType") String orderT @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + "AND o.status = 'COMPLETED' " + - "AND o.benRegID IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") List findBenRegIDsCompleted(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.status = 'EXPIRED' " + + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsPollingTimedOut(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); + + @Query("SELECT o.benRegID FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + + "AND o.status = 'FAILED' " + + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + + "WHERE o2.benRegID = o.benRegID AND o2.orderType = :orderType AND o2.deleted = false) " + + "AND o.benRegID IN (SELECT b.beneficiaryRegID FROM BenFlowStatus b WHERE b.deleted = false " + + "AND (:villageId IS NULL OR b.villageID = :villageId) " + + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") + List findBenRegIDsFailed(@Param("orderType") String orderType, + @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); } diff --git a/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java b/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java index f771afa6..4899d8c6 100644 --- a/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java +++ b/src/main/java/com/iemr/flw/service/DiagnosticDocumentService.java @@ -9,5 +9,5 @@ public interface DiagnosticDocumentService { void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String externalOrderId, DiagnosticDocumentAsset asset) throws Exception; - DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType) throws Exception; + DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType, Long visitCode) throws Exception; } diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java index f3ce6276..cb63e0c2 100644 --- a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -16,13 +16,13 @@ public interface DiagnosticOrderService { DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception; - DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType) throws Exception; + DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType, Long visitCode) throws Exception; - DiagnosticOrder markTestCompleted(Long benRegID, String orderType) throws Exception; + DiagnosticOrder markTestCompleted(Long benRegID, String orderType, Long visitCode) throws Exception; - DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType); + DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType, Long visitCode); - DiagnosticOrder getOrder(Long benRegID, String orderType) throws Exception; + DiagnosticOrder getOrder(Long benRegID, String orderType, Long visitCode) throws Exception; List getOrdersByBenRegId(Long benRegID) throws Exception; diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java index 665d426b..c0c2c919 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -1,10 +1,12 @@ package com.iemr.flw.service.impl; import com.iemr.flw.domain.iemr.DiagnosticDocument; +import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.dto.DiagnosticDocumentContent; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.masterEnum.DiagnosticDocumentType; import com.iemr.flw.repo.iemr.DiagnosticDocumentRepo; +import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.service.DiagnosticDocumentService; import com.iemr.flw.utils.CryptoUtil; import org.slf4j.Logger; @@ -36,6 +38,9 @@ public class DiagnosticDocumentServiceImpl implements DiagnosticDocumentService @Autowired private DiagnosticDocumentRepo diagnosticDocumentRepo; + @Autowired + private DiagnosticOrderRepo diagnosticOrderRepo; + @Autowired private CryptoUtil cryptoUtil; @@ -52,17 +57,17 @@ public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, String sha256Hash = sha256Hex(originalBytes); DiagnosticDocumentType documentType = DiagnosticDocumentType.from(orderType, asset.getType()); - Optional existing = - diagnosticDocumentRepo.findByBenRegIDAndDocumentTypeAndDeletedFalse(benRegID, documentType.name()); + Optional existing = diagnosticDocumentRepo + .findByDiagnosticOrderIdAndDocumentTypeAndDeletedFalse(diagnosticOrderId, documentType.name()); if (existing.isPresent() && sha256Hash.equals(existing.get().getSha256Hash())) { - logger.info("Diagnostic document already stored, skipping duplicate: benRegID={}, documentType={}", - benRegID, documentType); + logger.info("Diagnostic document already stored, skipping duplicate: diagnosticOrderId={}, documentType={}", + diagnosticOrderId, documentType); return; } long epochTime = Instant.now().toEpochMilli(); String storedFileName = documentType.name() + ".enc"; - String relativeDir = String.valueOf(benRegID); + String relativeDir = benRegID + "/" + diagnosticOrderId; // Documents must never touch disk unencrypted: encrypt in memory first, write only ciphertext. String base64Original = Base64.getEncoder().encodeToString(originalBytes); @@ -89,7 +94,7 @@ public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, try { diagnosticDocumentRepo.save(document); } catch (DataIntegrityViolationException dive) { - logger.warn("Lost document upsert race for benRegID={}, documentType={}", benRegID, documentType); + logger.warn("Lost document upsert race for diagnosticOrderId={}, documentType={}", diagnosticOrderId, documentType); return; } @@ -98,11 +103,24 @@ public void ingestAsset(Long diagnosticOrderId, Long benRegID, String orderType, } @Override - public DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType) throws Exception { + public DiagnosticDocumentContent fetch(Long benRegID, DiagnosticDocumentType documentType, Long visitCode) throws Exception { + String orderType = documentType.impliedOrderType().name(); + DiagnosticOrder order; + if (visitCode != null) { + order = diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType) + .orElseThrow(() -> new Exception("No diagnostic order found for benRegID=" + benRegID + + ", visitCode=" + visitCode + ", orderType=" + orderType)); + } else { + order = diagnosticOrderRepo + .findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, orderType) + .orElseThrow(() -> new Exception( + "No diagnostic order found for benRegID=" + benRegID + ", orderType=" + orderType)); + } + DiagnosticDocument document = diagnosticDocumentRepo - .findByBenRegIDAndDocumentTypeAndDeletedFalse(benRegID, documentType.name()) + .findByDiagnosticOrderIdAndDocumentTypeAndDeletedFalse(order.getId(), documentType.name()) .orElseThrow(() -> new Exception( - "No document found for benRegID=" + benRegID + ", documentType=" + documentType)); + "No document found for diagnosticOrderId=" + order.getId() + ", documentType=" + documentType)); Path filePath = Paths.get(storageRoot, document.getStoredPath()); String encryptedPayload = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 372cfe62..fbc68348 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -188,23 +188,36 @@ private DiagnosticOrder findLatestOrder(Long benRegID, String orderType) throws "DiagnosticOrder not found for benRegID=" + benRegID + ", orderType=" + orderType)); } + // Resolves a specific order by visitCode when given (retest disambiguation), otherwise + // falls back to "latest" - matching the pre-multi-order default callers already rely on. + private DiagnosticOrder resolveOrder(Long benRegID, String orderType, Long visitCode) throws Exception { + if (visitCode == null) { + return findLatestOrder(benRegID, orderType); + } + DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); + return diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, type.name()) + .orElseThrow(() -> new Exception("DiagnosticOrder not found for benRegID=" + benRegID + + ", visitCode=" + visitCode + ", orderType=" + orderType)); + } + @Override - public DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType) throws Exception { - DiagnosticOrder order = findLatestOrder(benRegID, orderType); + public DiagnosticOrderResultDto triggerManualPoll(Long benRegID, String orderType, Long visitCode) throws Exception { + DiagnosticOrder order = resolveOrder(benRegID, orderType, visitCode); DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); DiagnosticPollResult pollResult = provider.pollResult(order, true); return processResult(order, pollResult); } @Override - public DiagnosticOrder markTestCompleted(Long benRegID, String orderType) throws Exception { - DiagnosticOrder order = findLatestOrder(benRegID, orderType); + public DiagnosticOrder markTestCompleted(Long benRegID, String orderType, Long visitCode) throws Exception { + DiagnosticOrder order = resolveOrder(benRegID, orderType, visitCode); String status = order.getStatus(); if (order.getTestCompletedAt() != null) { Optional existingResult = diagnosticResultRepo.findByDiagnosticOrderIdAndDeletedFalse(order.getId()); boolean isTerminal = DiagnosticOrderStatus.COMPLETED.name().equals(status) - || DiagnosticOrderStatus.FAILED.name().equals(status); + || DiagnosticOrderStatus.FAILED.name().equals(status) + || DiagnosticOrderStatus.EXPIRED.name().equals(status); if (existingResult.isPresent() && isTerminal) { // Re-test: the order already completed once and has a result on file, and the // physical test was performed again — reopen it so the scheduler picks it back up @@ -239,8 +252,8 @@ public DiagnosticOrder markTestCompleted(Long benRegID, String orderType) throws } @Override - public DiagnosticOrder getOrder(Long benRegID, String orderType) throws Exception { - return findLatestOrder(benRegID, orderType); + public DiagnosticOrder getOrder(Long benRegID, String orderType, Long visitCode) throws Exception { + return resolveOrder(benRegID, orderType, visitCode); } @Override @@ -249,12 +262,13 @@ public List getOrdersByBenRegId(Long benRegID) throws Exception } @Override - public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType) { + public DiagnosticOrderResultDto getOrderResult(Long benRegID, String orderType, Long visitCode) { DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); dto.setOrderType(orderType); - Optional orderOpt = - diagnosticOrderRepo.findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, orderType); + Optional orderOpt = visitCode != null + ? diagnosticOrderRepo.findByBenRegIDAndVisitCodeAndOrderType(benRegID, visitCode, orderType) + : diagnosticOrderRepo.findFirstByBenRegIDAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(benRegID, orderType); if (orderOpt.isEmpty()) { dto.setStatus("NOT_FOUND"); return dto; @@ -285,6 +299,10 @@ public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, I .findBenRegIDsAwaitingProviderResult(type.name(), villageId, providerServiceMapId); List completed = diagnosticOrderRepo .findBenRegIDsCompleted(type.name(), villageId, providerServiceMapId); - return new DiagnosticOrderStatusSummaryDto(awaitingTestCompletion, awaitingProviderResult, completed); + List pollingTimedOut = diagnosticOrderRepo + .findBenRegIDsPollingTimedOut(type.name(), villageId, providerServiceMapId); + List failed = diagnosticOrderRepo + .findBenRegIDsFailed(type.name(), villageId, providerServiceMapId); + return new DiagnosticOrderStatusSummaryDto(awaitingTestCompletion, awaitingProviderResult, completed, pollingTimedOut, failed); } } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java index 8fed25a7..360c37f3 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -93,11 +93,11 @@ private boolean isExpired(Instant pollingStartedAt, Instant now) { } private void giveUp(DiagnosticOrder order) { - order.setStatus(DiagnosticOrderStatus.FAILED.name()); + order.setStatus(DiagnosticOrderStatus.EXPIRED.name()); order.setErrorMessage("Polling window exceeded (" + giveUpMinutes + " min) without a result"); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); diagnosticOrderRepo.save(order); - logger.warn("Order {} exceeded give-up window of {} min, marked FAILED", order.getId(), giveUpMinutes); + logger.warn("Order {} exceeded give-up window of {} min, marked EXPIRED", order.getId(), giveUpMinutes); } private void pollSingle(DiagnosticOrder order) {