From 534a7397f502aad4936629be6206b866bc693154 Mon Sep 17 00:00:00 2001 From: Sneha Date: Thu, 13 Aug 2026 12:07:25 +0530 Subject: [PATCH 1/3] feat(stoptb): add Nikshay ID Generator CSV export for camp beneficiaries Streams pending Stop TB camp beneficiaries (filtered by van/service point/date range, excluding those with an existing Nikshay ID) as a CSV formatted for the Nikshay ID Generator desktop app. Co-Authored-By: Claude Sonnet 5 --- .../stoptb/NikshayExportController.java | 100 ++++++++ .../repo/stoptb/NikshayExportRepository.java | 163 ++++++++++++ .../service/stoptb/NikshayExportService.java | 239 ++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java create mode 100644 src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java create mode 100644 src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java diff --git a/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java b/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java new file mode 100644 index 00000000..1026a8ac --- /dev/null +++ b/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java @@ -0,0 +1,100 @@ +/* +* 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.mmu.controller.stoptb; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import com.iemr.mmu.service.stoptb.NikshayExportService; + +import io.swagger.v3.oas.annotations.Operation; + +@RestController +@RequestMapping(value = "/stopTb/nikshay", headers = "Authorization") +@PreAuthorize("hasRole('NURSE') || hasRole('PHARMACIST') || hasRole('LABTECHNICIAN') || hasRole('DOCTOR') || hasRole('LAB_TECHNICIAN') || hasRole('TC_SPECIALIST') || hasRole('ONCOLOGIST') || hasRole('RADIOLOGIST')") +public class NikshayExportController { + private static final Logger logger = LoggerFactory.getLogger(NikshayExportController.class); + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ISO_LOCAL_DATE; + + @Autowired + private NikshayExportService nikshayExportService; + + @Operation(summary = "Download a Stop TB camp's beneficiaries as a CSV formatted for the Nikshay ID Generator") + @GetMapping(value = "/exportBeneficiariesCsv") + public ResponseEntity exportBeneficiariesCsv(@RequestParam("fromDate") String fromDateStr, + @RequestParam("toDate") String toDateStr, @RequestParam("vanID") Integer vanID, + @RequestParam("servicePointID") Integer servicePointID) { + + LocalDate fromDate; + LocalDate toDate; + try { + fromDate = LocalDate.parse(fromDateStr, DATE_FMT); + toDate = LocalDate.parse(toDateStr, DATE_FMT); + } catch (DateTimeParseException e) { + return ResponseEntity.badRequest().body("fromDate/toDate must be in YYYY-MM-DD format"); + } + if (toDate.isBefore(fromDate)) { + return ResponseEntity.badRequest().body("toDate must be on or after fromDate"); + } + if (vanID == null || servicePointID == null) { + return ResponseEntity.badRequest().body("vanID and servicePointID are required"); + } + + int excludedCount; + try { + excludedCount = nikshayExportService.countAlreadyGenerated(vanID, servicePointID, fromDate, toDate); + } catch (Exception e) { + logger.error("Error preparing Nikshay beneficiary export", e); + return ResponseEntity.status(500).body("Could not prepare the export"); + } + + StreamingResponseBody body = outputStream -> { + try { + nikshayExportService.streamBeneficiariesCsv(vanID, servicePointID, fromDate, toDate, outputStream); + } catch (Exception e) { + // The HTTP status/headers are already committed by the time streaming + // starts, so a mid-stream failure can only be logged, not surfaced + // as a clean error response. + logger.error("Error streaming Nikshay beneficiary CSV", e); + } + }; + + String filename = "nikshay-beneficiaries-" + fromDate + "-to-" + toDate + ".csv"; + return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv")) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .header("X-Excluded-Existing-Nikshay-Id-Count", String.valueOf(excludedCount)).body(body); + } +} diff --git a/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java new file mode 100644 index 00000000..81655de5 --- /dev/null +++ b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java @@ -0,0 +1,163 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.repo.stoptb; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.function.Consumer; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.stereotype.Repository; + +/** + * Reads Stop TB camp beneficiaries for the Nikshay ID Generator CSV export. + * + * Joins MMU's own beneficiary/demographic tables with the Stop TB dual-write + * tables (tb_stoptb_*) and the relevant master tables, resolving one row per + * beneficiary who has a visit in the requested van/service-point/date range. + * Beneficiaries that already have a Nikshay ID recorded in + * tb_stoptb_diagnostics are excluded from the streamed rows (they don't need + * a new one) but are counted separately so callers can report how many were + * skipped as already-generated. + */ +@Repository +public class NikshayExportRepository { + + @Autowired + private DataSource dataSource; + + private JdbcTemplate getJdbcTemplate() { + return new JdbcTemplate(dataSource); + } + + /** One camp beneficiary's raw, unmapped source data — Nikshay-vocabulary + * mapping/validation happens in the service layer, not here. */ + public record NikshayRawRow(String firstName, String middleLastName, Integer age, String gender, String phone, + String address, String stateName, String districtName, String healthFacility, String village, + String pincode, String maritalStatus, String caste, String occupation, String socioeconomicStatus, + String chiefComplaint, String hivStatus, Boolean isHivPos) { + } + + // Placeholders in order: [1] parkingPlaceID (facility-name join), [2] vanID, + // [3] parkingPlaceID (visit filter), [4] fromDate (inclusive), [5] toDate-exclusive-upper-bound. + private static final String BASE_SELECT = "SELECT " + + " b.FirstName AS firstName, " + + " TRIM(CONCAT(COALESCE(b.MiddleName,''),' ',COALESCE(b.LastName,''))) AS middleLastName, " + + " TIMESTAMPDIFF(YEAR, b.DOB, CURDATE()) AS age, " + + " g.GenderName AS gender, " + + " (SELECT p.PhoneNo FROM i_benphonemap p WHERE p.BenificiaryRegID = b.BeneficiaryRegID " + + " AND p.Deleted = 0 ORDER BY p.BenPhMapID ASC LIMIT 1) AS phone, " + + " TRIM(CONCAT_WS(', ', d.AddressLine1, d.AddressLine2, d.AddressLine3, d.AddressLine4, d.AddressLine5)) AS address, " + + " st.StateName AS stateName, " + + " dist.DistrictName AS districtName, " + + " pp.ParkingPlaceName AS healthFacility, " + + " vill.VillageName AS village, " + + " COALESCE(d.PinCode, vill.PinCode) AS pincode, " + + " ms.Status AS maritalStatus, " + + " c.CommunityType AS caste, " + + " occ.OccupationType AS occupation, " + + " inc.IncomeStatus AS socioeconomicStatus, " + + " (SELECT o.chief_complaint FROM tb_stoptb_general_opd o WHERE o.ben_reg_id = b.BeneficiaryRegID " + + " AND o.deleted = 0 ORDER BY o.id DESC LIMIT 1) AS chiefComplaint, " + + " (SELECT ge.hiv_status FROM tb_stoptb_general_examination ge WHERE ge.beneficiary_reg_id = b.BeneficiaryRegID " + + " AND ge.deleted = 0 ORDER BY ge.id DESC LIMIT 1) AS hivStatus, " + + " b.IsHIVPos AS isHivPos, " + + " (SELECT diag.nikshay_id FROM tb_stoptb_diagnostics diag WHERE diag.ben_reg_id = b.BeneficiaryRegID " + + " AND diag.nikshay_id IS NOT NULL AND diag.deleted = 0 ORDER BY diag.id DESC LIMIT 1) AS existingNikshayId " + + "FROM i_beneficiary b " + + "LEFT JOIN I_bendemographics d ON d.BeneficiaryRegID = b.BeneficiaryRegID " + + "LEFT JOIN m_gender g ON g.GenderID = b.GenderID " + + "LEFT JOIN m_maritalstatus ms ON ms.MaritalStatusID = b.MaritalStatusID " + + "LEFT JOIN m_community c ON c.CommunityID = d.CommunityID " + + "LEFT JOIN m_beneficiaryincomestatus inc ON inc.IncomeStatusID = d.IncomeStatusID " + + "LEFT JOIN m_beneficiaryoccupation occ ON occ.OccupationID = d.OccupationID " + + "LEFT JOIN m_DistrictBranchMapping vill ON vill.DistrictBranchID = d.DistrictBranchID " + + "LEFT JOIN m_parkingplace pp ON pp.ParkingPlaceID = ? " + + "LEFT JOIN m_state st ON st.StateID = pp.StateID " + + "LEFT JOIN m_district dist ON dist.DistrictID = pp.DistrictID " + + "WHERE b.Deleted = 0 " + + " AND b.BeneficiaryRegID IN ( " + + " SELECT DISTINCT v.beneficiary_reg_id FROM tb_stoptb_visit v " + + " WHERE v.vanID = ? AND v.parkingPlaceID = ? AND v.visit_date >= ? AND v.visit_date < ? " + + " )"; + + public int countAlreadyGenerated(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate) { + String sql = "SELECT COUNT(*) FROM (" + BASE_SELECT + ") t WHERE t.existingNikshayId IS NOT NULL"; + Integer count = getJdbcTemplate().query(sql, pss(vanID, servicePointID, fromDate, toDate), + rs -> rs.next() ? rs.getInt(1) : 0); + return count == null ? 0 : count; + } + + /** Streams every not-yet-Nikshay-ID'd beneficiary for the camp/date range to + * {@code rowConsumer} one row at a time, without materializing the full + * result set in memory — safe for large date ranges. */ + public void streamPendingBeneficiaries(Integer vanID, Integer servicePointID, LocalDate fromDate, + LocalDate toDate, Consumer rowConsumer) { + String sql = "SELECT * FROM (" + BASE_SELECT + ") t WHERE t.existingNikshayId IS NULL"; + JdbcTemplate jdbcTemplate = getJdbcTemplate(); + // MySQL Connector/J-specific: Integer.MIN_VALUE forces true row-by-row + // network streaming instead of buffering the whole result set client-side. + jdbcTemplate.setFetchSize(Integer.MIN_VALUE); + jdbcTemplate.query(sql, pss(vanID, servicePointID, fromDate, toDate), + (ResultSet rs) -> rowConsumer.accept(mapRow(rs))); + } + + private PreparedStatementSetter pss(Integer vanID, Integer servicePointID, LocalDate fromDate, + LocalDate toDate) { + return (PreparedStatement ps) -> { + ps.setInt(1, servicePointID); + ps.setInt(2, vanID); + ps.setInt(3, servicePointID); + ps.setTimestamp(4, Timestamp.valueOf(fromDate.atStartOfDay())); + ps.setTimestamp(5, Timestamp.valueOf(toDate.plusDays(1).atStartOfDay())); + }; + } + + private NikshayRawRow mapRow(ResultSet rs) throws SQLException { + return new NikshayRawRow( + rs.getString("firstName"), + rs.getString("middleLastName"), + rs.getObject("age", Integer.class), + rs.getString("gender"), + rs.getString("phone"), + rs.getString("address"), + rs.getString("stateName"), + rs.getString("districtName"), + rs.getString("healthFacility"), + rs.getString("village"), + rs.getString("pincode"), + rs.getString("maritalStatus"), + rs.getString("caste"), + rs.getString("occupation"), + rs.getString("socioeconomicStatus"), + rs.getString("chiefComplaint"), + rs.getString("hivStatus"), + rs.getObject("isHivPos", Boolean.class)); + } +} diff --git a/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java b/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java new file mode 100644 index 00000000..8ca9b49f --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java @@ -0,0 +1,239 @@ +/* +* 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.mmu.service.stoptb; + +import java.io.BufferedWriter; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.iemr.mmu.repo.stoptb.NikshayExportRepository; +import com.iemr.mmu.repo.stoptb.NikshayExportRepository.NikshayRawRow; + +/** + * Builds the Nikshay ID Generator CSV for a Stop TB camp. + * + * Column order/semantics follow the ID Generator's own data template + * (Nikshaya/nikshay-enrollment-app/docs/DATA_TEMPLATE.md): 20 fixed columns. + * The desktop app's own pre-flight validation (data.ts's REQUIRED_COLUMNS) + * treats every column except state/district/tu as non-blank-required — + * stricter than the template doc's "requested" language — so every + * categorical field below resolves to a real value: an exact + * (case-insensitive) match against Nikshay's allowed-value list when AMRIT's + * data lines up, otherwise a safe always-valid fallback (e.g. "Unknown"), + * never a guessed/fuzzy mapping onto the wrong specific label. + * + * Known gaps, best-effort until resolved elsewhere: + * - healthFacility/village: best-effort AMRIT names (m_parkingplace / + * m_DistrictBranchMapping). These are NOT yet verified against Nikshay's + * own separate location hierarchy/master list, which is what the portal + * actually validates against — this needs the Nikshay location resolver + * work (see Common-API's NikshayAddressResolver, unmerged as of writing). + * - occupation/area: no reliable AMRIT-to-Nikshay label mapping exists yet, + * so these always fall back to "Unknown" (a valid value for both). + * - symptoms: AMRIT has no structured Stop TB symptom checklist wired up yet + * (that data lives in the generic Dynamic Form response tables, whose + * question mapping isn't resolved). Best-effort: "Asymptomatic" when no + * chief complaint was recorded, "Others" otherwise. + * - gender has no safe generic fallback (no "Unknown" option in Nikshay's + * 3-value list) — left blank on an unmapped value rather than guessed, + * which will surface as a clear per-row error in the ID Generator app. + */ +@Service +public class NikshayExportService { + + private static final String[] CSV_HEADER = { "typeOfCaseFinding", "caste", "firstName", "middleLastName", "age", + "gender", "primaryPhone", "address", "state", "district", "tu", "healthFacility", "village", "pincode", + "area", "maritalStatus", "occupation", "socioeconomicStatus", "symptoms", "hivStatus" }; + + private static final Set GENDER_VALUES = setOf("Male", "Female", "Transgender"); + private static final Set CASTE_VALUES = setOf("SC", "ST", "Other"); + private static final Set MARITAL_VALUES = setOf("Single", "Married", "Unknown"); + private static final Set SOCIOECONOMIC_VALUES = setOf("APL", "BPL", "Unknown"); + private static final Set HIV_VALUES = setOf("Positive", "Reactive", "Non Reactive / Negative", "Unknown"); + // Verbatim from the ID Generator's own allowed-values list (docs/DATA_TEMPLATE.md) — + // spellings/typos are copied exactly as the portal defines them. + private static final Set OCCUPATION_VALUES = setOf("Legislators and Senior officials", + "Corporate Manager", "General Manager", + "Physical, mathematical and engineering science professional", + "Life sciences and health professional", "Teaching professional", "Other professional", "Office Clerk", + "Customer Services Clerks", "Personal Protective Service Providers", + "Models, Sales Persons and Demonstrators", "Market oriented skilled agriculutre and fishery workers", + "Subsitence agriculture and fishery workers", "Extraction and building trade workers", + "Metal, Machinery and related trades workers", + "Precision, handicraft, printing and related trade workers", + "Other Craft and related traders and workers", "Stationary Plant and related Operators", + "Machine Operators and Assembler", "Drivers and Mobile Plant Operators", + "Sales and Services elementry occupations", "Agriculture, fishery and related labour", + "Laborers in mining, construction, manufecturing and transport", "New Workers seeking employment", + "Workers reporting occupation unidentifiable or inadequately", "Workers no reporting any occupation", + "House Wife", "Unknown"); + + @Autowired + private NikshayExportRepository nikshayExportRepository; + + private static Set setOf(String... values) { + return new HashSet<>(Arrays.asList(values)); + } + + public int countAlreadyGenerated(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate) { + return nikshayExportRepository.countAlreadyGenerated(vanID, servicePointID, fromDate, toDate); + } + + /** Writes the CSV (header + one row per pending beneficiary) directly to + * {@code outputStream} as rows arrive from the database — never buffers + * the whole file in memory. Caller owns closing {@code outputStream}. */ + public void streamBeneficiariesCsv(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate, + OutputStream outputStream) { + Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); + try { + writer.write(String.join(",", CSV_HEADER)); + writer.write("\r\n"); + + nikshayExportRepository.streamPendingBeneficiaries(vanID, servicePointID, fromDate, toDate, row -> { + try { + writer.write(toCsvLine(row)); + writer.write("\r\n"); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + writer.flush(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private String toCsvLine(NikshayRawRow row) { + String[] values = { + "Passive", // typeOfCaseFinding - no Active/Passive signal in AMRIT yet; matches the portal's own default + matchOrDefault(row.caste(), CASTE_VALUES, "Other"), + nullToEmpty(row.firstName()), + nullToEmpty(row.middleLastName()), + ageOrBlank(row.age()), + matchOrBlank(row.gender(), GENDER_VALUES), + validPhoneOrBlank(row.phone()), + nullToEmpty(row.address()), + nullToEmpty(row.stateName()), // auto-filled from the Nikshay operator's own login on submit either way + nullToEmpty(row.districtName()), // same + "", // tu - no AMRIT equivalent; exempt from the app's own required-column check + nullToEmpty(row.healthFacility()), + nullToEmpty(row.village()), + validPincodeOrBlank(row.pincode()), + "Unknown", // area - no reliable AMRIT-to-Nikshay mapping available yet + matchOrDefault(row.maritalStatus(), MARITAL_VALUES, "Unknown"), + matchOrDefault(row.occupation(), OCCUPATION_VALUES, "Unknown"), + matchOrDefault(row.socioeconomicStatus(), SOCIOECONOMIC_VALUES, "Unknown"), + symptomsFrom(row.chiefComplaint()), + hivStatusFrom(row.hivStatus(), row.isHivPos()), + }; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < values.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(csvEscape(values[i])); + } + return sb.toString(); + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s.trim(); + } + + private static String matchOrBlank(String raw, Set allowed) { + if (raw == null) { + return ""; + } + String trimmed = raw.trim(); + for (String candidate : allowed) { + if (candidate.equalsIgnoreCase(trimmed)) { + return candidate; + } + } + return ""; + } + + private static String matchOrDefault(String raw, Set allowed, String fallback) { + String matched = matchOrBlank(raw, allowed); + return matched.isEmpty() ? fallback : matched; + } + + private static String ageOrBlank(Integer age) { + return (age != null && age >= 1 && age <= 99) ? String.valueOf(age) : ""; + } + + private static String validPhoneOrBlank(String raw) { + if (raw == null) { + return ""; + } + String digits = raw.replaceAll("[^0-9]", ""); + if (digits.length() > 10) { + digits = digits.substring(digits.length() - 10); + } + return digits.matches("[1-9][0-9]{9}") ? digits : ""; + } + + private static String validPincodeOrBlank(String raw) { + if (raw == null) { + return ""; + } + String trimmed = raw.trim(); + return trimmed.matches("[0-9]{6}") ? trimmed : ""; + } + + private static String symptomsFrom(String chiefComplaint) { + return (chiefComplaint == null || chiefComplaint.trim().isEmpty()) ? "Asymptomatic" : "Others"; + } + + private static String hivStatusFrom(String rawHivStatus, Boolean isHivPos) { + String matched = matchOrBlank(rawHivStatus, HIV_VALUES); + if (!matched.isEmpty()) { + return matched; + } + if (Boolean.TRUE.equals(isHivPos)) { + return "Positive"; + } + if (Boolean.FALSE.equals(isHivPos)) { + return "Non Reactive / Negative"; + } + return "Unknown"; + } + + private static String csvEscape(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + boolean needsQuoting = value.indexOf(',') >= 0 || value.indexOf('"') >= 0 || value.indexOf('\n') >= 0 + || value.indexOf('\r') >= 0; + String escaped = value.replace("\"", "\"\""); + return needsQuoting ? "\"" + escaped + "\"" : escaped; + } +} From 3debb133c7bdc6c89ef6b56b48fc314418943e56 Mon Sep 17 00:00:00 2001 From: Sneha Date: Thu, 13 Aug 2026 17:36:19 +0530 Subject: [PATCH 2/3] feat(stoptb): add Nikshay results CSV import to write back generated IDs Adds an export-batch tracking mechanism (row order + beneficiary/ diagnostics identity per CSV row) so the Nikshay ID Generator app's results file can be re-uploaded and matched back to the right beneficiary, since that app's own template strips any AMRIT identifier on read. Export now also returns a batch ID header; new POST /stopTb/nikshay/importResultsCsv writes nikshay_id back for success/single-ID-skip rows and flags ambiguous/failed rows for manual review instead of guessing. Co-Authored-By: Claude Sonnet 5 --- pom.xml | 6 + .../stoptb/NikshayExportController.java | 61 ++++++- .../repo/stoptb/NikshayExportRepository.java | 122 ++++++++++++-- .../service/stoptb/NikshayExportService.java | 19 ++- .../service/stoptb/NikshayImportService.java | 152 ++++++++++++++++++ 5 files changed, 344 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java diff --git a/pom.xml b/pom.xml index 990e3672..2d71530c 100644 --- a/pom.xml +++ b/pom.xml @@ -292,6 +292,12 @@ h2 runtime + + + org.apache.commons + commons-csv + 1.11.0 + ${project.artifactId}-${project.version} diff --git a/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java b/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java index 1026a8ac..d7b7b57b 100644 --- a/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java +++ b/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java @@ -33,14 +33,20 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import com.iemr.mmu.service.stoptb.NikshayExportService; +import com.iemr.mmu.service.stoptb.NikshayImportService; +import com.iemr.mmu.service.stoptb.NikshayImportService.ImportSummary; +import com.iemr.mmu.utils.JwtUtil; import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; @RestController @RequestMapping(value = "/stopTb/nikshay", headers = "Authorization") @@ -52,11 +58,33 @@ public class NikshayExportController { @Autowired private NikshayExportService nikshayExportService; + @Autowired + private NikshayImportService nikshayImportService; + + @Autowired + private JwtUtil jwtUtil; + + /** Best-effort — this is only used for a created_by/modified_by audit column, + * never for authorization (the filter chain/PreAuthorize already handled that). */ + private String currentUsername(HttpServletRequest request) { + try { + String header = request.getHeader("Authorization"); + if (header == null) { + return "unknown"; + } + String token = header.startsWith("Bearer ") ? header.substring(7) : header; + String username = jwtUtil.extractUsername(token); + return username != null ? username : "unknown"; + } catch (Exception e) { + return "unknown"; + } + } + @Operation(summary = "Download a Stop TB camp's beneficiaries as a CSV formatted for the Nikshay ID Generator") @GetMapping(value = "/exportBeneficiariesCsv") public ResponseEntity exportBeneficiariesCsv(@RequestParam("fromDate") String fromDateStr, @RequestParam("toDate") String toDateStr, @RequestParam("vanID") Integer vanID, - @RequestParam("servicePointID") Integer servicePointID) { + @RequestParam("servicePointID") Integer servicePointID, HttpServletRequest request) { LocalDate fromDate; LocalDate toDate; @@ -74,8 +102,13 @@ public ResponseEntity exportBeneficiariesCsv(@RequestParam("fromDate") String } int excludedCount; + Long batchId; try { excludedCount = nikshayExportService.countAlreadyGenerated(vanID, servicePointID, fromDate, toDate); + // Created synchronously, ahead of streaming, so its ID can go out as a + // response header — headers can't change once the streamed body starts. + batchId = nikshayExportService.createExportBatch(vanID, servicePointID, fromDate, toDate, + currentUsername(request)); } catch (Exception e) { logger.error("Error preparing Nikshay beneficiary export", e); return ResponseEntity.status(500).body("Could not prepare the export"); @@ -83,7 +116,8 @@ public ResponseEntity exportBeneficiariesCsv(@RequestParam("fromDate") String StreamingResponseBody body = outputStream -> { try { - nikshayExportService.streamBeneficiariesCsv(vanID, servicePointID, fromDate, toDate, outputStream); + nikshayExportService.streamBeneficiariesCsv(vanID, servicePointID, fromDate, toDate, outputStream, + batchId); } catch (Exception e) { // The HTTP status/headers are already committed by the time streaming // starts, so a mid-stream failure can only be logged, not surfaced @@ -95,6 +129,27 @@ public ResponseEntity exportBeneficiariesCsv(@RequestParam("fromDate") String String filename = "nikshay-beneficiaries-" + fromDate + "-to-" + toDate + ".csv"; return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv")) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") - .header("X-Excluded-Existing-Nikshay-Id-Count", String.valueOf(excludedCount)).body(body); + .header("X-Excluded-Existing-Nikshay-Id-Count", String.valueOf(excludedCount)) + .header("X-Nikshay-Export-Batch-Id", String.valueOf(batchId)).body(body); + } + + @Operation(summary = "Upload the Nikshay ID Generator app's results CSV to write generated Nikshay IDs " + + "back onto the beneficiaries from a prior export (identified by batchId)") + @PostMapping(value = "/importResultsCsv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity importResultsCsv(@RequestParam("batchId") Long batchId, + @RequestParam("file") MultipartFile file, HttpServletRequest request) { + if (file == null || file.isEmpty()) { + return ResponseEntity.badRequest().body("A results CSV file is required"); + } + try { + ImportSummary summary = nikshayImportService.importResults(batchId, file.getInputStream(), + currentUsername(request)); + return ResponseEntity.ok(summary); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } catch (Exception e) { + logger.error("Error importing Nikshay results CSV for batchId {}", batchId, e); + return ResponseEntity.status(500).body("Could not import the results file"); + } } } diff --git a/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java index 81655de5..1ff25e7e 100644 --- a/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java +++ b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java @@ -24,8 +24,10 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; import java.sql.Timestamp; import java.time.LocalDate; +import java.util.List; import java.util.function.Consumer; import javax.sql.DataSource; @@ -33,6 +35,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; /** @@ -57,16 +61,23 @@ private JdbcTemplate getJdbcTemplate() { } /** One camp beneficiary's raw, unmapped source data — Nikshay-vocabulary - * mapping/validation happens in the service layer, not here. */ - public record NikshayRawRow(String firstName, String middleLastName, Integer age, String gender, String phone, - String address, String stateName, String districtName, String healthFacility, String village, - String pincode, String maritalStatus, String caste, String occupation, String socioeconomicStatus, - String chiefComplaint, String hivStatus, Boolean isHivPos) { + * mapping/validation happens in the service layer, not here. benRegId/diagnosticsId + * are AMRIT-internal bookkeeping (for export-batch row tracking), not CSV output. */ + public record NikshayRawRow(Long benRegId, Long diagnosticsId, String firstName, String middleLastName, + Integer age, String gender, String phone, String address, String stateName, String districtName, + String healthFacility, String village, String pincode, String maritalStatus, String caste, + String occupation, String socioeconomicStatus, String chiefComplaint, String hivStatus, + Boolean isHivPos) { } - // Placeholders in order: [1] parkingPlaceID (facility-name join), [2] vanID, - // [3] parkingPlaceID (visit filter), [4] fromDate (inclusive), [5] toDate-exclusive-upper-bound. + // Placeholders in order: [1] vanID, [2] parkingPlaceID (both for the diagnosticsId + // lookup), [3] parkingPlaceID (facility-name join), [4] vanID, [5] parkingPlaceID + // (visit filter), [6] fromDate (inclusive), [7] toDate-exclusive-upper-bound. private static final String BASE_SELECT = "SELECT " + + " b.BeneficiaryRegID AS benRegId, " + + " (SELECT diag2.id FROM tb_stoptb_diagnostics diag2 WHERE diag2.ben_reg_id = b.BeneficiaryRegID " + + " AND diag2.vanID = ? AND diag2.parkingPlaceID = ? AND diag2.deleted = 0 " + + " ORDER BY diag2.id DESC LIMIT 1) AS diagnosticsId, " + " b.FirstName AS firstName, " + " TRIM(CONCAT(COALESCE(b.MiddleName,''),' ',COALESCE(b.LastName,''))) AS middleLastName, " + " TIMESTAMPDIFF(YEAR, b.DOB, CURDATE()) AS age, " @@ -131,16 +142,20 @@ public void streamPendingBeneficiaries(Integer vanID, Integer servicePointID, Lo private PreparedStatementSetter pss(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate) { return (PreparedStatement ps) -> { - ps.setInt(1, servicePointID); - ps.setInt(2, vanID); + ps.setInt(1, vanID); + ps.setInt(2, servicePointID); ps.setInt(3, servicePointID); - ps.setTimestamp(4, Timestamp.valueOf(fromDate.atStartOfDay())); - ps.setTimestamp(5, Timestamp.valueOf(toDate.plusDays(1).atStartOfDay())); + ps.setInt(4, vanID); + ps.setInt(5, servicePointID); + ps.setTimestamp(6, Timestamp.valueOf(fromDate.atStartOfDay())); + ps.setTimestamp(7, Timestamp.valueOf(toDate.plusDays(1).atStartOfDay())); }; } private NikshayRawRow mapRow(ResultSet rs) throws SQLException { return new NikshayRawRow( + rs.getObject("benRegId", Long.class), + rs.getObject("diagnosticsId", Long.class), rs.getString("firstName"), rs.getString("middleLastName"), rs.getObject("age", Integer.class), @@ -160,4 +175,89 @@ private NikshayRawRow mapRow(ResultSet rs) throws SQLException { rs.getString("hivStatus"), rs.getObject("isHivPos", Boolean.class)); } + + /** Camp/date-range parameters an export batch was created for — needed on the + * import side to create a diagnostics row for a beneficiary that didn't have one yet. */ + public record ExportBatch(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate) { + } + + /** One tracked CSV row: which beneficiary/diagnostics row it corresponds to. */ + public record ExportBatchRow(int rowIndex, Long benRegId, Long diagnosticsId) { + } + + /** Creates the batch header row up front (before streaming starts) so its ID can + * go out as a response header immediately, ahead of the streamed CSV body. */ + public Long createExportBatch(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate, + String createdBy) { + String sql = "INSERT INTO tb_stoptb_nikshay_export_batch " + + "(vanID, parkingPlaceID, from_date, to_date, created_by) VALUES (?, ?, ?, ?, ?)"; + KeyHolder keyHolder = new GeneratedKeyHolder(); + getJdbcTemplate().update(connection -> { + PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); + ps.setInt(1, vanID); + ps.setInt(2, servicePointID); + ps.setDate(3, java.sql.Date.valueOf(fromDate)); + ps.setDate(4, java.sql.Date.valueOf(toDate)); + ps.setString(5, createdBy); + return ps; + }, keyHolder); + return keyHolder.getKey().longValue(); + } + + /** Records one CSV row's beneficiary/diagnostics identity against the batch, + * called as each row is streamed out. */ + public void addBatchRow(Long batchId, int rowIndex, Long benRegId, Long diagnosticsId) { + String sql = "INSERT INTO tb_stoptb_nikshay_export_batch_row " + + "(batch_id, row_index, ben_reg_id, diagnostics_id) VALUES (?, ?, ?, ?)"; + getJdbcTemplate().update(sql, batchId, rowIndex, benRegId, diagnosticsId); + } + + public void finalizeBatchRowCount(Long batchId, int rowCount) { + getJdbcTemplate().update("UPDATE tb_stoptb_nikshay_export_batch SET row_count = ? WHERE id = ?", rowCount, + batchId); + } + + public ExportBatch getBatch(Long batchId) { + String sql = "SELECT vanID, parkingPlaceID, from_date, to_date FROM tb_stoptb_nikshay_export_batch WHERE id = ?"; + return getJdbcTemplate().query(sql, (ResultSet rs) -> rs.next() + ? new ExportBatch(rs.getInt("vanID"), rs.getInt("parkingPlaceID"), rs.getDate("from_date").toLocalDate(), + rs.getDate("to_date").toLocalDate()) + : null, batchId); + } + + /** All tracked rows for a batch, in the same order they were streamed into the CSV. */ + public List getBatchRows(Long batchId) { + String sql = "SELECT row_index, ben_reg_id, diagnostics_id FROM tb_stoptb_nikshay_export_batch_row " + + "WHERE batch_id = ? ORDER BY row_index ASC"; + return getJdbcTemplate().query(sql, + (rs, rowNum) -> new ExportBatchRow(rs.getInt("row_index"), rs.getLong("ben_reg_id"), + rs.getObject("diagnostics_id", Long.class)), + batchId); + } + + public void updateNikshayId(Long diagnosticsId, String nikshayId, String modifiedBy) { + String sql = "UPDATE tb_stoptb_diagnostics SET nikshay_id = ?, modified_by = ?, " + + "last_mod_date = CURRENT_TIMESTAMP WHERE id = ?"; + getJdbcTemplate().update(sql, nikshayId, modifiedBy, diagnosticsId); + } + + /** Called when a beneficiary had no tb_stoptb_diagnostics row for this camp visit yet + * at export time — creates one to hold the Nikshay ID the portal generated. */ + public Long insertDiagnosticsWithNikshayId(Long benRegId, Integer vanID, Integer servicePointID, + LocalDate visitDate, String nikshayId, String createdBy) { + String sql = "INSERT INTO tb_stoptb_diagnostics " + + "(ben_reg_id, vanID, parkingPlaceID, visit_date, nikshay_id, created_by) VALUES (?, ?, ?, ?, ?, ?)"; + KeyHolder keyHolder = new GeneratedKeyHolder(); + getJdbcTemplate().update(connection -> { + PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, benRegId); + ps.setInt(2, vanID); + ps.setInt(3, servicePointID); + ps.setTimestamp(4, Timestamp.valueOf(visitDate.atStartOfDay())); + ps.setString(5, nikshayId); + ps.setString(6, createdBy); + return ps; + }, keyHolder); + return keyHolder.getKey().longValue(); + } } diff --git a/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java b/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java index 8ca9b49f..fd6a9292 100644 --- a/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java +++ b/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java @@ -107,25 +107,40 @@ public int countAlreadyGenerated(Integer vanID, Integer servicePointID, LocalDat return nikshayExportRepository.countAlreadyGenerated(vanID, servicePointID, fromDate, toDate); } + /** Creates the export batch header row up front, before the CSV starts streaming, + * so its ID is available for a response header the caller can hand back on import. */ + public Long createExportBatch(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate, + String createdBy) { + return nikshayExportRepository.createExportBatch(vanID, servicePointID, fromDate, toDate, createdBy); + } + /** Writes the CSV (header + one row per pending beneficiary) directly to * {@code outputStream} as rows arrive from the database — never buffers - * the whole file in memory. Caller owns closing {@code outputStream}. */ + * the whole file in memory. Caller owns closing {@code outputStream}. + * + * Also records each row's beneficiary/diagnostics identity against + * {@code batchId}, in CSV row order — the only way to match a beneficiary + * back up once the Nikshay ID Generator app's results file comes back, + * since that app strips any column not in its own fixed template. */ public void streamBeneficiariesCsv(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate, - OutputStream outputStream) { + OutputStream outputStream, Long batchId) { Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); try { writer.write(String.join(",", CSV_HEADER)); writer.write("\r\n"); + int[] rowIndex = { 0 }; nikshayExportRepository.streamPendingBeneficiaries(vanID, servicePointID, fromDate, toDate, row -> { try { writer.write(toCsvLine(row)); writer.write("\r\n"); + nikshayExportRepository.addBatchRow(batchId, rowIndex[0]++, row.benRegId(), row.diagnosticsId()); } catch (Exception e) { throw new RuntimeException(e); } }); writer.flush(); + nikshayExportRepository.finalizeBatchRowCount(batchId, rowIndex[0]); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java b/src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java new file mode 100644 index 00000000..cc7e4b79 --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java @@ -0,0 +1,152 @@ +/* +* 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.mmu.service.stoptb; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.iemr.mmu.repo.stoptb.NikshayExportRepository; +import com.iemr.mmu.repo.stoptb.NikshayExportRepository.ExportBatch; +import com.iemr.mmu.repo.stoptb.NikshayExportRepository.ExportBatchRow; + +/** + * Imports the Nikshay ID Generator desktop app's results CSV, writing the + * portal-generated Nikshay IDs back onto the beneficiaries a prior export + * (identified by {@code batchId}) streamed out. + * + * Matching is purely by row position: results file row i corresponds to + * export batch row i. That app's own CSV template has no room for an + * AMRIT-internal identifier — reading strips any column outside its fixed + * 20-column list — but it does guarantee it never reorders or drops rows + * between the input it read and the results it writes (confirmed from its + * own source: output is built as {@code rows.map((r, i) => ...)}), which is + * what makes position-based matching safe as long as the row count matches + * the original export exactly. + * + * Row status handling: + * - "success": generatedId is the new Nikshay ID — written as-is. + * - "skipped" (portal-detected duplicate): generatedId is one or more + * existing patient IDs, space-separated. A single ID is written the same + * as a success; more than one is ambiguous and left for manual review + * rather than guessed. + * - "failed": never written; surfaced in the response for visibility. + */ +@Service +public class NikshayImportService { + + private static final List REQUIRED_COLUMNS = List.of("firstName", "middleLastName", "generatedId", + "status"); + + public record ImportRowResult(int rowIndex, Long benRegId, String firstName, String middleLastName, + String status, String generatedId, String note) { + } + + public record ImportSummary(int csvRowCount, int batchRowCount, int updated, int failed, int needsReview, + List needsReviewRows, List failedRows) { + } + + @Autowired + private NikshayExportRepository nikshayExportRepository; + + public ImportSummary importResults(Long batchId, InputStream csvInputStream, String modifiedBy) + throws Exception { + ExportBatch batch = nikshayExportRepository.getBatch(batchId); + if (batch == null) { + throw new IllegalArgumentException("Unknown batchId: " + batchId); + } + List batchRows = nikshayExportRepository.getBatchRows(batchId); + + List records; + boolean hasErrorColumn; + CSVFormat format = CSVFormat.DEFAULT.builder().setHeader().setSkipHeaderRecord(true).setTrim(true).build(); + try (CSVParser parser = new CSVParser(new InputStreamReader(csvInputStream, StandardCharsets.UTF_8), + format)) { + Map header = parser.getHeaderMap(); + for (String required : REQUIRED_COLUMNS) { + if (!header.containsKey(required)) { + throw new IllegalArgumentException("Results CSV is missing required column: " + required); + } + } + hasErrorColumn = header.containsKey("error"); + records = parser.getRecords(); + } + + if (records.size() != batchRows.size()) { + throw new IllegalArgumentException("Results CSV has " + records.size() + " row(s) but export batch " + + batchId + " has " + batchRows.size() + + " — this file doesn't match that export (wrong file, or rows were added/removed)."); + } + + int updated = 0; + List needsReview = new ArrayList<>(); + List failedRows = new ArrayList<>(); + + for (int i = 0; i < records.size(); i++) { + CSVRecord record = records.get(i); + ExportBatchRow batchRow = batchRows.get(i); + + String status = record.get("status").trim(); + String generatedId = record.get("generatedId").trim(); + String firstName = record.get("firstName"); + String middleLastName = record.get("middleLastName"); + + if ("success".equalsIgnoreCase(status) || "skipped".equalsIgnoreCase(status)) { + String[] tokens = generatedId.isEmpty() ? new String[0] : generatedId.split("\\s+"); + if (tokens.length == 1) { + writeNikshayId(batch, batchRow, tokens[0], modifiedBy); + updated++; + } else { + String note = tokens.length == 0 ? "Row marked " + status + " but has no generatedId." + : "Multiple possible existing Nikshay IDs (" + generatedId + ") — needs manual confirmation."; + needsReview.add(new ImportRowResult(i, batchRow.benRegId(), firstName, middleLastName, status, + generatedId, note)); + } + } else { + String error = hasErrorColumn ? record.get("error") : ""; + failedRows.add(new ImportRowResult(i, batchRow.benRegId(), firstName, middleLastName, status, + generatedId, error)); + } + } + + return new ImportSummary(records.size(), batchRows.size(), updated, failedRows.size(), needsReview.size(), + needsReview, failedRows); + } + + private void writeNikshayId(ExportBatch batch, ExportBatchRow batchRow, String nikshayId, String modifiedBy) { + if (batchRow.diagnosticsId() != null) { + nikshayExportRepository.updateNikshayId(batchRow.diagnosticsId(), nikshayId, modifiedBy); + } else { + nikshayExportRepository.insertDiagnosticsWithNikshayId(batchRow.benRegId(), batch.vanID(), + batch.servicePointID(), batch.fromDate(), nikshayId, modifiedBy); + } + } +} From ad1b3bd7caed7e26ef466f41287366c2585872e5 Mon Sep 17 00:00:00 2001 From: Sneha Date: Fri, 14 Aug 2026 14:11:40 +0530 Subject: [PATCH 3/3] refactor(stoptb): replace batch-tracking tables with a benRegId passthrough column Adds benRegId as a CSV column the Nikshay ID Generator app carries through untouched (a companion change in that app), so each results row is self-identifying instead of needing AMRIT-side row-order tracking. Import now resolves/creates the right tb_stoptb_diagnostics row live via findLatestDiagnosticsId, keyed off benRegId directly. Drops the export-batch header/param from both endpoints. Co-Authored-By: Claude Sonnet 5 --- .../stoptb/NikshayExportController.java | 29 ++--- .../repo/stoptb/NikshayExportRepository.java | 105 +++++------------- .../service/stoptb/NikshayExportService.java | 30 ++--- .../service/stoptb/NikshayImportService.java | 97 ++++++++-------- 4 files changed, 101 insertions(+), 160 deletions(-) diff --git a/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java b/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java index d7b7b57b..1e9d918c 100644 --- a/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java +++ b/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java @@ -102,13 +102,8 @@ public ResponseEntity exportBeneficiariesCsv(@RequestParam("fromDate") String } int excludedCount; - Long batchId; try { excludedCount = nikshayExportService.countAlreadyGenerated(vanID, servicePointID, fromDate, toDate); - // Created synchronously, ahead of streaming, so its ID can go out as a - // response header — headers can't change once the streamed body starts. - batchId = nikshayExportService.createExportBatch(vanID, servicePointID, fromDate, toDate, - currentUsername(request)); } catch (Exception e) { logger.error("Error preparing Nikshay beneficiary export", e); return ResponseEntity.status(500).body("Could not prepare the export"); @@ -116,8 +111,7 @@ public ResponseEntity exportBeneficiariesCsv(@RequestParam("fromDate") String StreamingResponseBody body = outputStream -> { try { - nikshayExportService.streamBeneficiariesCsv(vanID, servicePointID, fromDate, toDate, outputStream, - batchId); + nikshayExportService.streamBeneficiariesCsv(vanID, servicePointID, fromDate, toDate, outputStream); } catch (Exception e) { // The HTTP status/headers are already committed by the time streaming // starts, so a mid-stream failure can only be logged, not surfaced @@ -129,26 +123,33 @@ public ResponseEntity exportBeneficiariesCsv(@RequestParam("fromDate") String String filename = "nikshay-beneficiaries-" + fromDate + "-to-" + toDate + ".csv"; return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv")) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") - .header("X-Excluded-Existing-Nikshay-Id-Count", String.valueOf(excludedCount)) - .header("X-Nikshay-Export-Batch-Id", String.valueOf(batchId)).body(body); + .header("X-Excluded-Existing-Nikshay-Id-Count", String.valueOf(excludedCount)).body(body); } @Operation(summary = "Upload the Nikshay ID Generator app's results CSV to write generated Nikshay IDs " - + "back onto the beneficiaries from a prior export (identified by batchId)") + + "back onto the beneficiaries — each row is matched by its own benRegId column, " + + "a pass-through field the export added that the ID Generator app never touches") @PostMapping(value = "/importResultsCsv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity importResultsCsv(@RequestParam("batchId") Long batchId, + public ResponseEntity importResultsCsv(@RequestParam("vanID") Integer vanID, + @RequestParam("servicePointID") Integer servicePointID, @RequestParam("visitDate") String visitDateStr, @RequestParam("file") MultipartFile file, HttpServletRequest request) { if (file == null || file.isEmpty()) { return ResponseEntity.badRequest().body("A results CSV file is required"); } + LocalDate visitDate; try { - ImportSummary summary = nikshayImportService.importResults(batchId, file.getInputStream(), - currentUsername(request)); + visitDate = LocalDate.parse(visitDateStr, DATE_FMT); + } catch (DateTimeParseException e) { + return ResponseEntity.badRequest().body("visitDate must be in YYYY-MM-DD format"); + } + try { + ImportSummary summary = nikshayImportService.importResults(vanID, servicePointID, visitDate, + file.getInputStream(), currentUsername(request)); return ResponseEntity.ok(summary); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(e.getMessage()); } catch (Exception e) { - logger.error("Error importing Nikshay results CSV for batchId {}", batchId, e); + logger.error("Error importing Nikshay results CSV", e); return ResponseEntity.status(500).body("Could not import the results file"); } } diff --git a/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java index 1ff25e7e..119a1958 100644 --- a/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java +++ b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java @@ -27,7 +27,6 @@ import java.sql.Statement; import java.sql.Timestamp; import java.time.LocalDate; -import java.util.List; import java.util.function.Consumer; import javax.sql.DataSource; @@ -61,23 +60,20 @@ private JdbcTemplate getJdbcTemplate() { } /** One camp beneficiary's raw, unmapped source data — Nikshay-vocabulary - * mapping/validation happens in the service layer, not here. benRegId/diagnosticsId - * are AMRIT-internal bookkeeping (for export-batch row tracking), not CSV output. */ - public record NikshayRawRow(Long benRegId, Long diagnosticsId, String firstName, String middleLastName, - Integer age, String gender, String phone, String address, String stateName, String districtName, - String healthFacility, String village, String pincode, String maritalStatus, String caste, - String occupation, String socioeconomicStatus, String chiefComplaint, String hivStatus, - Boolean isHivPos) { + * mapping/validation happens in the service layer, not here. benRegId is + * carried into the CSV itself (as a pass-through column the Nikshay ID + * Generator app never touches) so results can be matched back to a + * beneficiary on import without needing any AMRIT-side row tracking. */ + public record NikshayRawRow(Long benRegId, String firstName, String middleLastName, Integer age, String gender, + String phone, String address, String stateName, String districtName, String healthFacility, + String village, String pincode, String maritalStatus, String caste, String occupation, + String socioeconomicStatus, String chiefComplaint, String hivStatus, Boolean isHivPos) { } - // Placeholders in order: [1] vanID, [2] parkingPlaceID (both for the diagnosticsId - // lookup), [3] parkingPlaceID (facility-name join), [4] vanID, [5] parkingPlaceID - // (visit filter), [6] fromDate (inclusive), [7] toDate-exclusive-upper-bound. + // Placeholders in order: [1] parkingPlaceID (facility-name join), [2] vanID, + // [3] parkingPlaceID (visit filter), [4] fromDate (inclusive), [5] toDate-exclusive-upper-bound. private static final String BASE_SELECT = "SELECT " + " b.BeneficiaryRegID AS benRegId, " - + " (SELECT diag2.id FROM tb_stoptb_diagnostics diag2 WHERE diag2.ben_reg_id = b.BeneficiaryRegID " - + " AND diag2.vanID = ? AND diag2.parkingPlaceID = ? AND diag2.deleted = 0 " - + " ORDER BY diag2.id DESC LIMIT 1) AS diagnosticsId, " + " b.FirstName AS firstName, " + " TRIM(CONCAT(COALESCE(b.MiddleName,''),' ',COALESCE(b.LastName,''))) AS middleLastName, " + " TIMESTAMPDIFF(YEAR, b.DOB, CURDATE()) AS age, " @@ -142,20 +138,17 @@ public void streamPendingBeneficiaries(Integer vanID, Integer servicePointID, Lo private PreparedStatementSetter pss(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate) { return (PreparedStatement ps) -> { - ps.setInt(1, vanID); - ps.setInt(2, servicePointID); + ps.setInt(1, servicePointID); + ps.setInt(2, vanID); ps.setInt(3, servicePointID); - ps.setInt(4, vanID); - ps.setInt(5, servicePointID); - ps.setTimestamp(6, Timestamp.valueOf(fromDate.atStartOfDay())); - ps.setTimestamp(7, Timestamp.valueOf(toDate.plusDays(1).atStartOfDay())); + ps.setTimestamp(4, Timestamp.valueOf(fromDate.atStartOfDay())); + ps.setTimestamp(5, Timestamp.valueOf(toDate.plusDays(1).atStartOfDay())); }; } private NikshayRawRow mapRow(ResultSet rs) throws SQLException { return new NikshayRawRow( rs.getObject("benRegId", Long.class), - rs.getObject("diagnosticsId", Long.class), rs.getString("firstName"), rs.getString("middleLastName"), rs.getObject("age", Integer.class), @@ -176,63 +169,15 @@ private NikshayRawRow mapRow(ResultSet rs) throws SQLException { rs.getObject("isHivPos", Boolean.class)); } - /** Camp/date-range parameters an export batch was created for — needed on the - * import side to create a diagnostics row for a beneficiary that didn't have one yet. */ - public record ExportBatch(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate) { - } - - /** One tracked CSV row: which beneficiary/diagnostics row it corresponds to. */ - public record ExportBatchRow(int rowIndex, Long benRegId, Long diagnosticsId) { - } - - /** Creates the batch header row up front (before streaming starts) so its ID can - * go out as a response header immediately, ahead of the streamed CSV body. */ - public Long createExportBatch(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate, - String createdBy) { - String sql = "INSERT INTO tb_stoptb_nikshay_export_batch " - + "(vanID, parkingPlaceID, from_date, to_date, created_by) VALUES (?, ?, ?, ?, ?)"; - KeyHolder keyHolder = new GeneratedKeyHolder(); - getJdbcTemplate().update(connection -> { - PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); - ps.setInt(1, vanID); - ps.setInt(2, servicePointID); - ps.setDate(3, java.sql.Date.valueOf(fromDate)); - ps.setDate(4, java.sql.Date.valueOf(toDate)); - ps.setString(5, createdBy); - return ps; - }, keyHolder); - return keyHolder.getKey().longValue(); - } - - /** Records one CSV row's beneficiary/diagnostics identity against the batch, - * called as each row is streamed out. */ - public void addBatchRow(Long batchId, int rowIndex, Long benRegId, Long diagnosticsId) { - String sql = "INSERT INTO tb_stoptb_nikshay_export_batch_row " - + "(batch_id, row_index, ben_reg_id, diagnostics_id) VALUES (?, ?, ?, ?)"; - getJdbcTemplate().update(sql, batchId, rowIndex, benRegId, diagnosticsId); - } - - public void finalizeBatchRowCount(Long batchId, int rowCount) { - getJdbcTemplate().update("UPDATE tb_stoptb_nikshay_export_batch SET row_count = ? WHERE id = ?", rowCount, - batchId); - } - - public ExportBatch getBatch(Long batchId) { - String sql = "SELECT vanID, parkingPlaceID, from_date, to_date FROM tb_stoptb_nikshay_export_batch WHERE id = ?"; - return getJdbcTemplate().query(sql, (ResultSet rs) -> rs.next() - ? new ExportBatch(rs.getInt("vanID"), rs.getInt("parkingPlaceID"), rs.getDate("from_date").toLocalDate(), - rs.getDate("to_date").toLocalDate()) - : null, batchId); - } - - /** All tracked rows for a batch, in the same order they were streamed into the CSV. */ - public List getBatchRows(Long batchId) { - String sql = "SELECT row_index, ben_reg_id, diagnostics_id FROM tb_stoptb_nikshay_export_batch_row " - + "WHERE batch_id = ? ORDER BY row_index ASC"; - return getJdbcTemplate().query(sql, - (rs, rowNum) -> new ExportBatchRow(rs.getInt("row_index"), rs.getLong("ben_reg_id"), - rs.getObject("diagnostics_id", Long.class)), - batchId); + /** The most recent tb_stoptb_diagnostics row for this beneficiary at this + * van/service point, if any — looked up live at import time (no export-time + * snapshot needed, since the beneficiary is identified directly from the + * results CSV's own benRegId column). Null if none exists yet. */ + public Long findLatestDiagnosticsId(Long benRegId, Integer vanID, Integer servicePointID) { + String sql = "SELECT id FROM tb_stoptb_diagnostics WHERE ben_reg_id = ? AND vanID = ? " + + "AND parkingPlaceID = ? AND deleted = 0 ORDER BY id DESC LIMIT 1"; + return getJdbcTemplate().query(sql, (ResultSet rs) -> rs.next() ? rs.getLong("id") : null, benRegId, vanID, + servicePointID); } public void updateNikshayId(Long diagnosticsId, String nikshayId, String modifiedBy) { @@ -241,8 +186,8 @@ public void updateNikshayId(Long diagnosticsId, String nikshayId, String modifie getJdbcTemplate().update(sql, nikshayId, modifiedBy, diagnosticsId); } - /** Called when a beneficiary had no tb_stoptb_diagnostics row for this camp visit yet - * at export time — creates one to hold the Nikshay ID the portal generated. */ + /** Called when a beneficiary had no tb_stoptb_diagnostics row for this camp + * visit yet — creates one to hold the Nikshay ID the portal generated. */ public Long insertDiagnosticsWithNikshayId(Long benRegId, Integer vanID, Integer servicePointID, LocalDate visitDate, String nikshayId, String createdBy) { String sql = "INSERT INTO tb_stoptb_diagnostics " diff --git a/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java b/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java index fd6a9292..074b8846 100644 --- a/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java +++ b/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java @@ -69,9 +69,13 @@ @Service public class NikshayExportService { - private static final String[] CSV_HEADER = { "typeOfCaseFinding", "caste", "firstName", "middleLastName", "age", - "gender", "primaryPhone", "address", "state", "district", "tu", "healthFacility", "village", "pincode", - "area", "maritalStatus", "occupation", "socioeconomicStatus", "symptoms", "hivStatus" }; + // benRegId is a pass-through column, not one of Nikshay's own template fields — the ID + // Generator app never reads or displays it, but carries it straight through to the + // results file, which is how an uploaded results CSV gets matched back to a beneficiary. + private static final String[] CSV_HEADER = { "benRegId", "typeOfCaseFinding", "caste", "firstName", + "middleLastName", "age", "gender", "primaryPhone", "address", "state", "district", "tu", "healthFacility", + "village", "pincode", "area", "maritalStatus", "occupation", "socioeconomicStatus", "symptoms", + "hivStatus" }; private static final Set GENDER_VALUES = setOf("Male", "Female", "Transgender"); private static final Set CASTE_VALUES = setOf("SC", "ST", "Other"); @@ -107,40 +111,25 @@ public int countAlreadyGenerated(Integer vanID, Integer servicePointID, LocalDat return nikshayExportRepository.countAlreadyGenerated(vanID, servicePointID, fromDate, toDate); } - /** Creates the export batch header row up front, before the CSV starts streaming, - * so its ID is available for a response header the caller can hand back on import. */ - public Long createExportBatch(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate, - String createdBy) { - return nikshayExportRepository.createExportBatch(vanID, servicePointID, fromDate, toDate, createdBy); - } - /** Writes the CSV (header + one row per pending beneficiary) directly to * {@code outputStream} as rows arrive from the database — never buffers - * the whole file in memory. Caller owns closing {@code outputStream}. - * - * Also records each row's beneficiary/diagnostics identity against - * {@code batchId}, in CSV row order — the only way to match a beneficiary - * back up once the Nikshay ID Generator app's results file comes back, - * since that app strips any column not in its own fixed template. */ + * the whole file in memory. Caller owns closing {@code outputStream}. */ public void streamBeneficiariesCsv(Integer vanID, Integer servicePointID, LocalDate fromDate, LocalDate toDate, - OutputStream outputStream, Long batchId) { + OutputStream outputStream) { Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); try { writer.write(String.join(",", CSV_HEADER)); writer.write("\r\n"); - int[] rowIndex = { 0 }; nikshayExportRepository.streamPendingBeneficiaries(vanID, servicePointID, fromDate, toDate, row -> { try { writer.write(toCsvLine(row)); writer.write("\r\n"); - nikshayExportRepository.addBatchRow(batchId, rowIndex[0]++, row.benRegId(), row.diagnosticsId()); } catch (Exception e) { throw new RuntimeException(e); } }); writer.flush(); - nikshayExportRepository.finalizeBatchRowCount(batchId, rowIndex[0]); } catch (Exception e) { throw new RuntimeException(e); } @@ -148,6 +137,7 @@ public void streamBeneficiariesCsv(Integer vanID, Integer servicePointID, LocalD private String toCsvLine(NikshayRawRow row) { String[] values = { + String.valueOf(row.benRegId()), "Passive", // typeOfCaseFinding - no Active/Passive signal in AMRIT yet; matches the portal's own default matchOrDefault(row.caste(), CASTE_VALUES, "Other"), nullToEmpty(row.firstName()), diff --git a/src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java b/src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java index cc7e4b79..6222e3a6 100644 --- a/src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java +++ b/src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java @@ -24,6 +24,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -35,22 +36,19 @@ import org.springframework.stereotype.Service; import com.iemr.mmu.repo.stoptb.NikshayExportRepository; -import com.iemr.mmu.repo.stoptb.NikshayExportRepository.ExportBatch; -import com.iemr.mmu.repo.stoptb.NikshayExportRepository.ExportBatchRow; /** * Imports the Nikshay ID Generator desktop app's results CSV, writing the - * portal-generated Nikshay IDs back onto the beneficiaries a prior export - * (identified by {@code batchId}) streamed out. + * portal-generated Nikshay IDs back onto the right beneficiaries. * - * Matching is purely by row position: results file row i corresponds to - * export batch row i. That app's own CSV template has no room for an - * AMRIT-internal identifier — reading strips any column outside its fixed - * 20-column list — but it does guarantee it never reorders or drops rows - * between the input it read and the results it writes (confirmed from its - * own source: output is built as {@code rows.map((r, i) => ...)}), which is - * what makes position-based matching safe as long as the row count matches - * the original export exactly. + * Each row is identified by its own {@code benRegId} column — a pass-through + * field the exported CSV carries that isn't one of Nikshay's own template + * columns, so the ID Generator app never touches it but does carry it + * straight through to the results file (see NikshayExportService's + * CSV_HEADER, and the corresponding change in the Nikshaya app itself). + * That means no AMRIT-side row/order tracking is needed: each result row is + * self-identifying, and the beneficiary's tb_stoptb_diagnostics row (if any) + * is resolved live at import time. * * Row status handling: * - "success": generatedId is the new Nikshay ID — written as-is. @@ -63,28 +61,22 @@ @Service public class NikshayImportService { - private static final List REQUIRED_COLUMNS = List.of("firstName", "middleLastName", "generatedId", - "status"); + private static final List REQUIRED_COLUMNS = List.of("benRegId", "firstName", "middleLastName", + "generatedId", "status"); public record ImportRowResult(int rowIndex, Long benRegId, String firstName, String middleLastName, String status, String generatedId, String note) { } - public record ImportSummary(int csvRowCount, int batchRowCount, int updated, int failed, int needsReview, + public record ImportSummary(int csvRowCount, int updated, int failed, int needsReview, List needsReviewRows, List failedRows) { } @Autowired private NikshayExportRepository nikshayExportRepository; - public ImportSummary importResults(Long batchId, InputStream csvInputStream, String modifiedBy) - throws Exception { - ExportBatch batch = nikshayExportRepository.getBatch(batchId); - if (batch == null) { - throw new IllegalArgumentException("Unknown batchId: " + batchId); - } - List batchRows = nikshayExportRepository.getBatchRows(batchId); - + public ImportSummary importResults(Integer vanID, Integer servicePointID, LocalDate visitDate, + InputStream csvInputStream, String modifiedBy) throws Exception { List records; boolean hasErrorColumn; CSVFormat format = CSVFormat.DEFAULT.builder().setHeader().setSkipHeaderRecord(true).setTrim(true).build(); @@ -100,53 +92,66 @@ public ImportSummary importResults(Long batchId, InputStream csvInputStream, Str records = parser.getRecords(); } - if (records.size() != batchRows.size()) { - throw new IllegalArgumentException("Results CSV has " + records.size() + " row(s) but export batch " - + batchId + " has " + batchRows.size() - + " — this file doesn't match that export (wrong file, or rows were added/removed)."); - } - int updated = 0; List needsReview = new ArrayList<>(); List failedRows = new ArrayList<>(); for (int i = 0; i < records.size(); i++) { CSVRecord record = records.get(i); - ExportBatchRow batchRow = batchRows.get(i); - - String status = record.get("status").trim(); - String generatedId = record.get("generatedId").trim(); String firstName = record.get("firstName"); String middleLastName = record.get("middleLastName"); + String status = record.get("status").trim(); + String generatedId = record.get("generatedId").trim(); + + Long benRegId = parseBenRegId(record.get("benRegId")); + if (benRegId == null) { + failedRows.add(new ImportRowResult(i, null, firstName, middleLastName, status, generatedId, + "Row has a missing/invalid benRegId — was this file exported by AMRIT?")); + continue; + } if ("success".equalsIgnoreCase(status) || "skipped".equalsIgnoreCase(status)) { String[] tokens = generatedId.isEmpty() ? new String[0] : generatedId.split("\\s+"); if (tokens.length == 1) { - writeNikshayId(batch, batchRow, tokens[0], modifiedBy); + writeNikshayId(vanID, servicePointID, visitDate, benRegId, tokens[0], modifiedBy); updated++; } else { String note = tokens.length == 0 ? "Row marked " + status + " but has no generatedId." - : "Multiple possible existing Nikshay IDs (" + generatedId + ") — needs manual confirmation."; - needsReview.add(new ImportRowResult(i, batchRow.benRegId(), firstName, middleLastName, status, - generatedId, note)); + : "Multiple possible existing Nikshay IDs (" + generatedId + + ") — needs manual confirmation."; + needsReview.add( + new ImportRowResult(i, benRegId, firstName, middleLastName, status, generatedId, note)); } } else { String error = hasErrorColumn ? record.get("error") : ""; - failedRows.add(new ImportRowResult(i, batchRow.benRegId(), firstName, middleLastName, status, - generatedId, error)); + failedRows.add( + new ImportRowResult(i, benRegId, firstName, middleLastName, status, generatedId, error)); } } - return new ImportSummary(records.size(), batchRows.size(), updated, failedRows.size(), needsReview.size(), - needsReview, failedRows); + return new ImportSummary(records.size(), updated, failedRows.size(), needsReview.size(), needsReview, + failedRows); + } + + private static Long parseBenRegId(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Long.valueOf(raw.trim()); + } catch (NumberFormatException e) { + return null; + } } - private void writeNikshayId(ExportBatch batch, ExportBatchRow batchRow, String nikshayId, String modifiedBy) { - if (batchRow.diagnosticsId() != null) { - nikshayExportRepository.updateNikshayId(batchRow.diagnosticsId(), nikshayId, modifiedBy); + private void writeNikshayId(Integer vanID, Integer servicePointID, LocalDate visitDate, Long benRegId, + String nikshayId, String modifiedBy) { + Long diagnosticsId = nikshayExportRepository.findLatestDiagnosticsId(benRegId, vanID, servicePointID); + if (diagnosticsId != null) { + nikshayExportRepository.updateNikshayId(diagnosticsId, nikshayId, modifiedBy); } else { - nikshayExportRepository.insertDiagnosticsWithNikshayId(batchRow.benRegId(), batch.vanID(), - batch.servicePointID(), batch.fromDate(), nikshayId, modifiedBy); + nikshayExportRepository.insertDiagnosticsWithNikshayId(benRegId, vanID, servicePointID, visitDate, + nikshayId, modifiedBy); } } }