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
new file mode 100644
index 00000000..1e9d918c
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/controller/stoptb/NikshayExportController.java
@@ -0,0 +1,156 @@
+/*
+* 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.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")
+@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;
+
+ @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, HttpServletRequest request) {
+
+ 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);
+ }
+
+ @Operation(summary = "Upload the Nikshay ID Generator app's results CSV to write generated Nikshay IDs "
+ + "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("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 {
+ 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", 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
new file mode 100644
index 00000000..119a1958
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java
@@ -0,0 +1,208 @@
+/*
+* 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.Statement;
+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.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+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. 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] 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, "
+ + " 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.getObject("benRegId", Long.class),
+ 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));
+ }
+
+ /** 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) {
+ 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 — 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
new file mode 100644
index 00000000..074b8846
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/stoptb/NikshayExportService.java
@@ -0,0 +1,244 @@
+/*
+* 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 {
+
+ // 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");
+ 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 = {
+ 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()),
+ 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;
+ }
+}
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..6222e3a6
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/stoptb/NikshayImportService.java
@@ -0,0 +1,157 @@
+/*
+* 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.time.LocalDate;
+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;
+
+/**
+ * Imports the Nikshay ID Generator desktop app's results CSV, writing the
+ * portal-generated Nikshay IDs back onto the right beneficiaries.
+ *
+ * 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.
+ * - "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("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 updated, int failed, int needsReview,
+ List needsReviewRows, List failedRows) {
+ }
+
+ @Autowired
+ private NikshayExportRepository nikshayExportRepository;
+
+ 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();
+ 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();
+ }
+
+ int updated = 0;
+ List needsReview = new ArrayList<>();
+ List failedRows = new ArrayList<>();
+
+ for (int i = 0; i < records.size(); i++) {
+ CSVRecord record = records.get(i);
+ 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(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, benRegId, firstName, middleLastName, status, generatedId, note));
+ }
+ } else {
+ String error = hasErrorColumn ? record.get("error") : "";
+ failedRows.add(
+ new ImportRowResult(i, benRegId, firstName, middleLastName, status, generatedId, error));
+ }
+ }
+
+ 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(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(benRegId, vanID, servicePointID, visitDate,
+ nikshayId, modifiedBy);
+ }
+ }
+}