diff --git a/pom.xml b/pom.xml
index dd4e04a0..5749c203 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
4.0.0
com.iemr.mmu
mmu-api
- 3.9.0
+ 3.12.0
war
MMU-API
@@ -294,7 +294,12 @@
h2
runtime
-
+
+
+ software.amazon.awssdk
+ s3
+ 2.25.60
+
org.apache.commons
commons-csv
diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties
index faf5180f..cd33f081 100644
--- a/src/main/environment/common_ci.properties
+++ b/src/main/environment/common_ci.properties
@@ -5,6 +5,12 @@ spring.datasource.username=@env.DATABASE_USERNAME@
spring.datasource.password=@env.DATABASE_PASSWORD@
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
+## S3 storage for diagnostic documents pushed from a van
+aws.s3.access-key=@env.AWS_S3_ACCESS_KEY@
+aws.s3.secret-key=@env.AWS_S3_SECRET_KEY@
+aws.s3.region=@env.AWS_S3_REGION@
+diagnostic.documents.s3.bucket=@env.DIAGNOSTIC_DOCUMENTS_S3_BUCKET@
+
## Carestream URLs, local
carestreamOrderCreateURL=@env.COMMON_API@carestream/createOrder
@@ -28,6 +34,11 @@ dataSyncUploadUrl=@env.MMU_CENTRAL_SERVER@dataSync/van-to-server
## Data download API, central
dataSyncDownloadUrl=@env.MMU_CENTRAL_SERVER@dataSync/server-to-van
+## Diagnostic document push (this server -> further central server)
+diagnosticDocumentUploadUrl=@env.MMU_CENTRAL_SERVER@dataSync/diagnostic-documents
+diagnosticDocument.push.batchSize=@env.DIAGNOSTIC_DOCUMENT_PUSH_BATCH_SIZE@
+diagnostic.documents.storage-root=@env.DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT@
+
## TC specialist slot booking, # not required
tcSpecialistSlotBook=@env.TM_API@schedule/bookSlot
@@ -95,4 +106,10 @@ logging.file.name=@env.MMU_API_LOGGING_FILE_NAME@
springdoc.api-docs.enabled=@env.SWAGGER_DOC_ENABLED@
springdoc.swagger-ui.enabled=@env.SWAGGER_DOC_ENABLED@
-cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@
\ No newline at end of file
+cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@
+
+## BenID auto top-up URLs (feature stays off: stoptb.* not set for these builds)
+benCheckUrlLocal=@env.IDENTITY_API_BASE_URL@id/checkAvailablBenIDLocalServer
+centralLoginUrl=@env.COMMON_API_OPEN_SYNC@user/userAuthenticate
+centralRefreshTokenUrl=@env.COMMON_API_OPEN_SYNC@user/refreshToken
+centralSessionUrl=@env.COMMON_API_OPEN_SYNC@user/getLoginResponse
diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties
index 28f6b89e..05a361a2 100644
--- a/src/main/environment/common_docker.properties
+++ b/src/main/environment/common_docker.properties
@@ -5,6 +5,12 @@ spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
+## S3 storage for diagnostic documents pushed from a van
+aws.s3.access-key=${AWS_S3_ACCESS_KEY:}
+aws.s3.secret-key=${AWS_S3_SECRET_KEY:}
+aws.s3.region=${AWS_S3_REGION:ap-south-1}
+diagnostic.documents.s3.bucket=${DIAGNOSTIC_DOCUMENTS_S3_BUCKET:}
+
## Carestream URLs, local
carestreamOrderCreateURL=${COMMON_API}carestream/createOrder
@@ -28,6 +34,11 @@ dataSyncUploadUrl=${MMU_CENTRAL_SERVER}dataSync/van-to-server
## Data download API, central
dataSyncDownloadUrl=${MMU_CENTRAL_SERVER}dataSync/server-to-van
+## Diagnostic document push (this server -> further central server)
+diagnosticDocumentUploadUrl=${MMU_CENTRAL_SERVER}dataSync/diagnostic-documents
+diagnosticDocument.push.batchSize=${DIAGNOSTIC_DOCUMENT_PUSH_BATCH_SIZE:3}
+diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT}
+
## TC specialist slot booking, # not required
tcSpecialistSlotBook=${TM_API}schedule/bookSlot
@@ -93,4 +104,18 @@ logging.path=logs
logging.file.name=${MMU_API_LOGGING_FILE_NAME}
springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED}
-springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED}
\ No newline at end of file
+springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED}
+
+## BenID auto top-up (StopTB laptops only; off unless STOPTB_ENFORCE_VANID=true and STOPTB_VAN_ID>0)
+benCheckUrlLocal=${IDENTITY_API_BASE_URL}id/checkAvailablBenIDLocalServer
+centralLoginUrl=${COMMON_API_OPEN_SYNC}user/userAuthenticate
+centralRefreshTokenUrl=${COMMON_API_OPEN_SYNC}user/refreshToken
+centralSessionUrl=${COMMON_API_OPEN_SYNC}user/getLoginResponse
+stoptb.enforce.vanid=${STOPTB_ENFORCE_VANID:false}
+stoptb.van.id=${STOPTB_VAN_ID:0}
+benid.pool.lowerLimit=${BENID_POOL_LOWER_LIMIT:2000}
+benid.pool.batchSize=${BENID_POOL_BATCH_SIZE:1000}
+benid.pool.bootstrapSize=${BENID_POOL_BOOTSTRAP_SIZE:10000}
+benid.token.dir=${BENID_TOKEN_DIR:/opt/amrit/data}
+benid.autoTopUp.initialDelayMs=${BENID_AUTO_TOPUP_INITIAL_DELAY_MS:120000}
+benid.autoTopUp.checkIntervalMs=${BENID_AUTO_TOPUP_CHECK_INTERVAL_MS:300000}
diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties
index 294c0e45..46996a54 100644
--- a/src/main/environment/common_example.properties
+++ b/src/main/environment/common_example.properties
@@ -7,6 +7,12 @@ spring.datasource.username=
spring.datasource.password=
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
+## S3 storage for diagnostic documents pushed from a van (POST /dataSync/diagnostic-documents)
+aws.s3.access-key=
+aws.s3.secret-key=
+aws.s3.region=ap-south-1
+diagnostic.documents.s3.bucket=
+
## Carestream URLs, local
#carestreamOrderCreateURL =http://localhost:1040/carestream/createOrder
carestreamOrderCreateURL =http://localhost:8083/carestream/createOrder
@@ -44,6 +50,10 @@ dataSyncUploadUrl=http://10.208.122.38:8080/mmu-api/dataSync/van-to-server
#dataSyncDownloadUrl=http://localhost:82/dataSync/server-to-van
dataSyncDownloadUrl=http://10.208.122.38:8080/mmu-api/dataSync/server-to-van
+diagnosticDocumentUploadUrl=http://localhost:8087/mmu-api/dataSync/diagnostic-documents
+diagnosticDocument.push.batchSize=3
+diagnostic.documents.storage-root=
+
## TC specialist slot booking, # not required
#tcSpecialistSlotBook=http://localhost:8080/schedule/bookSlot
tcSpecialistSlotBook=http://10.208.122.38:8080/tm-api/schedule/bookSlot
@@ -110,4 +120,32 @@ logging.path=logs/
logging.file.name=logs/mmu-api.log
-cors.allowed-origins=http://localhost:*
\ No newline at end of file
+cors.allowed-origins=http://localhost:*
+
+## BenID auto top-up (StopTB laptops only)
+benCheckUrlLocal=http://localhost:8094/id/checkAvailablBenIDLocalServer
+centralLoginUrl=https://uatstoptb.piramalswasthya.org/common-api/user/userAuthenticate
+centralRefreshTokenUrl=https://uatstoptb.piramalswasthya.org/common-api/user/refreshToken
+centralSessionUrl=https://uatstoptb.piramalswasthya.org/common-api/user/getLoginResponse
+stoptb.enforce.vanid=false
+stoptb.van.id=0
+benid.pool.lowerLimit=2000
+benid.pool.batchSize=1000
+benid.pool.bootstrapSize=10000
+benid.token.dir=/opt/amrit/data
+benid.autoTopUp.initialDelayMs=120000
+benid.autoTopUp.checkIntervalMs=300000
+
+## Automatic Data Sync (StopTB laptops only; needs the BenID central connection above). Off by default: manual sync only.
+sync.autoUpload.enabled=false
+sync.autoUpload.intervalMs=1800000
+sync.autoMasterDownload.enabled=false
+sync.autoMasterDownload.intervalMs=21600000
+# automatic master download fetches only inventory masters (items, stores, item->store mapping); false = all masters
+sync.autoMasterDownload.inventoryOnly=true
+sync.autoInventoryDownload.enabled=false
+
+## Central upload: replace van IDs in link columns (BenVisitID, PrescriptionID) by central IDs.
+## Off by default (other MMU servers unchanged); on for the StopTB central.
+## Env alternative: DATASYNC_TRANSLATEVANLINKS=true
+dataSync.translateVanLinks=false
diff --git a/src/main/java/com/iemr/mmu/Application.java b/src/main/java/com/iemr/mmu/Application.java
index 712d34f5..8fb40ccf 100644
--- a/src/main/java/com/iemr/mmu/Application.java
+++ b/src/main/java/com/iemr/mmu/Application.java
@@ -29,11 +29,13 @@
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
+import org.springframework.scheduling.annotation.EnableScheduling;
import com.iemr.mmu.data.login.Users;
import com.iemr.mmu.utils.IEMRApplBeans;
@SpringBootApplication
+@EnableScheduling
public class Application {
public static void main(String[] args) {
diff --git a/src/main/java/com/iemr/mmu/config/S3ClientConfig.java b/src/main/java/com/iemr/mmu/config/S3ClientConfig.java
new file mode 100644
index 00000000..eb3509bf
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/config/S3ClientConfig.java
@@ -0,0 +1,53 @@
+package com.iemr.mmu.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+
+/***
+ * @purpose S3 client beans used to store diagnostic documents pushed from a van. Built
+ * once and shared, rather than re-built per request.
+ */
+@Configuration
+public class S3ClientConfig {
+
+ @Value("${aws.s3.access-key}")
+ private String accessKey;
+
+ @Value("${aws.s3.secret-key}")
+ private String secretKey;
+
+ @Value("${aws.s3.region}")
+ private String region;
+
+ /*
+ * Only the central server talks to S3; a van (camp laptop) runs the same war but never
+ * calls it, so it must be able to start without AWS keys. AwsBasicCredentials rejects blank
+ * keys at construction time, so fall back to the SDK default chain, which is resolved lazily
+ * on the first S3 call rather than at startup.
+ */
+ private AwsCredentialsProvider credentialsProvider() {
+ if (accessKey == null || accessKey.isBlank() || secretKey == null || secretKey.isBlank()) {
+ return DefaultCredentialsProvider.create();
+ }
+ return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey));
+ }
+
+ @Bean
+ public S3Client diagnosticDocumentS3Client() {
+ return S3Client.builder().region(Region.of(region)).credentialsProvider(credentialsProvider()).build();
+ }
+
+ @Bean
+ public S3Presigner diagnosticDocumentS3Presigner() {
+ return S3Presigner.builder().region(Region.of(region)).credentialsProvider(credentialsProvider()).build();
+ }
+}
diff --git a/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java b/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java
new file mode 100644
index 00000000..71533a5d
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java
@@ -0,0 +1,94 @@
+/*
+* AMRIT – Accessible Medical Records via Integrated Technology
+* Integrated EHR (Electronic Health Records) Solution
+*
+* Copyright (C) "Piramal Swasthya Management and Research Institute"
+*
+* This file is part of AMRIT.
+*
+* This program is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published by
+* the Free Software Foundation, either version 3 of the License, or
+* (at your option) any later version.
+*
+* This program is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+package com.iemr.mmu.controller.benid;
+
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.iemr.mmu.service.benid.BenIdAutoTopUpService;
+import com.iemr.mmu.service.benid.CentralAuthService;
+import com.iemr.mmu.utils.response.OutputResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+
+@RestController
+@RequestMapping(value = "/centralAuth", headers = "Authorization", produces = "application/json")
+public class CentralAuthController {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
+
+ @Autowired
+ private CentralAuthService centralAuthService;
+
+ @Autowired
+ private BenIdAutoTopUpService benIdAutoTopUpService;
+
+ @Operation(summary = "Automatic BenID top-up and central connection status")
+ @GetMapping(value = "/status")
+ public String status() {
+ OutputResponse response = new OutputResponse();
+ try {
+ response.setResponse(benIdAutoTopUpService.status().toString());
+ } catch (Exception e) {
+ logger.error("Error reading BenID auto top-up status: " + e.getMessage());
+ response.setError(e);
+ }
+ return response.toString();
+ }
+
+ /**
+ * Called by MMU-UI right after a successful login. Returns immediately; the
+ * central login and any top-up happen in the background.
+ */
+ @Operation(summary = "Connect this laptop to central with the user's login (one time)")
+ @PostMapping(value = "/capture", consumes = "application/json")
+ public String capture(@RequestBody String requestOBJ) {
+ OutputResponse response = new OutputResponse();
+ try {
+ if (!centralAuthService.isEnabled()) {
+ response.setResponse("disabled");
+ return response.toString();
+ }
+ JSONObject obj = new JSONObject(requestOBJ);
+ String userName = obj.optString("userName", null);
+ String password = obj.optString("password", null);
+ boolean force = obj.optBoolean("reconnect", false);
+ benIdAutoTopUpService.runInBackground(() -> {
+ if (centralAuthService.capture(userName, password, force)) {
+ benIdAutoTopUpService.triggerNow();
+ }
+ });
+ response.setResponse("accepted");
+ } catch (Exception e) {
+ logger.error("Error in central capture: " + e.getMessage());
+ response.setError(e);
+ }
+ return response.toString();
+ }
+}
diff --git a/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java b/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java
index a7f2a7c7..973fdf41 100644
--- a/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java
+++ b/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java
@@ -21,6 +21,8 @@
*/
package com.iemr.mmu.controller.dataSyncActivity;
+import java.util.Map;
+
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,13 +33,18 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.google.gson.Gson;
+import com.iemr.mmu.service.benid.BenIdAutoTopUpService;
import com.iemr.mmu.service.dataSyncActivity.DownSyncDataFromServerImpl;
import com.iemr.mmu.service.dataSyncActivity.DownloadDataFromServerImpl;
import com.iemr.mmu.service.dataSyncActivity.DownloadDataFromServerTransactionalImpl;
+import com.iemr.mmu.service.dataSyncActivity.AutoSyncService;
import com.iemr.mmu.service.dataSyncActivity.UploadDataToServerImpl;
+import com.iemr.mmu.service.dataSyncLayerCentral.DiagnosticDocumentFetchService;
+import com.iemr.mmu.service.dataSyncLayerCentral.DiagnosticDocumentPushServiceImpl;
import com.iemr.mmu.utils.CookieUtil;
import com.iemr.mmu.utils.response.OutputResponse;
@@ -60,7 +67,18 @@ public class StartSyncActivity {
@Autowired
private DownloadDataFromServerTransactionalImpl downloadDataFromServerTransactionalImpl;
@Autowired
+ private DiagnosticDocumentPushServiceImpl diagnosticDocumentPushServiceImpl;
+ @Autowired
+ private DiagnosticDocumentFetchService diagnosticDocumentFetchService;
+ @Autowired
private DownSyncDataFromServerImpl downSyncDataFromServerImpl;
+
+ @Autowired
+ private BenIdAutoTopUpService benIdAutoTopUpService;
+
+ @Autowired
+ private AutoSyncService autoSyncService;
+
private static final String GROUP_ID = "groupID";
private static final String PROVIDER_SERVICE_MAP_ID = "providerServiceMapID";
@@ -79,6 +97,8 @@ public String dataSyncToServer(@RequestBody String requestOBJ,
serverAuthorization, jwtToken);
// if (s != null)
response.setResponse(s);
+ // internet is up right now: top up the BenID pool if it is low
+ benIdAutoTopUpService.triggerNow();
// else
// response.setError(5000, "Error in data sync");
} else {
@@ -91,6 +111,45 @@ public String dataSyncToServer(@RequestBody String requestOBJ,
return response.toStringWithSerialization();
}
+ @Operation(summary = "Push pending diagnostic documents (docsProcessed='N') to the further central server, in batches")
+ @PostMapping(value = { "/diagnostic-documents-to-server" })
+ public String diagnosticDocumentsToServer(@RequestHeader(value = "Authorization") String authorization,
+ @RequestHeader(value = "ServerAuthorization") String serverAuthorization,
+ @RequestParam(required = false) Long villageId) {
+ OutputResponse response = new OutputResponse();
+ try {
+ String s = diagnosticDocumentPushServiceImpl.pushPendingDocuments(serverAuthorization);
+ if (s != null)
+ response.setResponse(s);
+ else
+ response.setError(5000, "Error in diagnostic document push");
+ } catch (Exception e) {
+ logger.error("Error in diagnostic document push : " + e);
+ response.setError(e);
+ }
+ return response.toString();
+ }
+
+ @Operation(summary = "Fetch a short-lived download URL for the latest successfully-pushed diagnostic document matching a beneficiary+documentType, from this van's own local record")
+ @GetMapping(value = { "/diagnostic-documents/download" })
+ public String diagnosticDocumentDownloadUrl(@RequestParam Long beneficiaryId, @RequestParam String documentType,
+ @RequestHeader(value = "Authorization") String authorization) {
+ OutputResponse response = new OutputResponse();
+ try {
+ Map download = diagnosticDocumentFetchService.getLatestDocumentDownload(beneficiaryId,
+ documentType);
+ if (download != null)
+ response.setResponse(new Gson().toJson(download));
+ else
+ response.setError(5000, "No pushed document found for beneficiaryId=" + beneficiaryId
+ + ", documentType=" + documentType);
+ } catch (Exception e) {
+ logger.error("Error fetching diagnostic document download URL : " + e);
+ response.setError(e);
+ }
+ return response.toString();
+ }
+
@Operation(summary = "Get data sync group details")
@GetMapping(value = { "/getSyncGroupDetails" })
public String getSyncGroupDetails() {
@@ -131,8 +190,11 @@ public String startMasterDownload(@RequestBody String requestOBJ,
if (s.equalsIgnoreCase("inProgress"))
response.setError(5000,
"Download is already in progress from different device, kindly wait to finish this");
- else
+ else {
response.setResponse(s);
+ // internet is up right now: top up the BenID pool if it is low
+ benIdAutoTopUpService.triggerNow();
+ }
} else
response.setError(5000, s);
} else {
@@ -147,6 +209,19 @@ public String startMasterDownload(@RequestBody String requestOBJ,
return response.toString();
}
+ @Operation(summary = "Automatic data sync status and the rows central rejected on upload")
+ @GetMapping(value = { "/autoSyncStatus" })
+ public String autoSyncStatus() {
+ OutputResponse response = new OutputResponse();
+ try {
+ response.setResponse(autoSyncService.status().toString());
+ } catch (Exception e) {
+ logger.error("Error while getting automatic sync status : " + e);
+ response.setError(e);
+ }
+ return response.toString();
+ }
+
@Operation(summary = "Data sync master download progress check")
@GetMapping(value = { "/checkMastersDownloadProgress" })
public String checkMastersDownloadProgress() {
diff --git a/src/main/java/com/iemr/mmu/controller/dataSyncLayerCentral/MMUDataSyncVanToServer.java b/src/main/java/com/iemr/mmu/controller/dataSyncLayerCentral/MMUDataSyncVanToServer.java
index 589e1dcb..1d2c8790 100644
--- a/src/main/java/com/iemr/mmu/controller/dataSyncLayerCentral/MMUDataSyncVanToServer.java
+++ b/src/main/java/com/iemr/mmu/controller/dataSyncLayerCentral/MMUDataSyncVanToServer.java
@@ -24,7 +24,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -35,11 +34,11 @@
import com.iemr.mmu.data.syncActivity_syncLayer.DownSyncDataDigester;
import com.iemr.mmu.data.syncActivity_syncLayer.SyncDownloadMaster;
import com.iemr.mmu.data.syncActivity_syncLayer.SyncUploadDataDigester;
+import com.iemr.mmu.service.dataSyncLayerCentral.DiagnosticDocumentIngestService;
import com.iemr.mmu.service.dataSyncLayerCentral.FetchDownloadDataImpl;
import com.iemr.mmu.service.dataSyncLayerCentral.GetDataFromVanAndSyncToDBImpl;
import com.iemr.mmu.service.dataSyncLayerCentral.GetDownSyncDataFromCentralImpl;
import com.iemr.mmu.service.dataSyncLayerCentral.GetMasterDataFromCentralForVanImpl;
-import com.iemr.mmu.utils.CookieUtil;
import com.iemr.mmu.utils.response.OutputResponse;
import io.swagger.v3.oas.annotations.Operation;
@@ -61,6 +60,8 @@ public class MMUDataSyncVanToServer {
private FetchDownloadDataImpl fetchDownloadDataImpl;
@Autowired
private GetDownSyncDataFromCentralImpl getDownSyncDataFromCentralImpl;
+ @Autowired
+ private DiagnosticDocumentIngestService diagnosticDocumentIngestService;
@Operation(summary = "Sync data from van-to-server")
@PostMapping(value = { "/van-to-server" }, consumes = "application/json", produces = "application/json")
@@ -81,6 +82,24 @@ public String dataSyncToServer(@RequestBody String requestOBJ,
return response.toString();
}
+ @Operation(summary = "Receive diagnostic documents pushed from a van and store each in S3 (no database write here)")
+ @PostMapping(value = { "/diagnostic-documents" }, consumes = "application/json", produces = "application/json")
+ public String diagnosticDocumentsFromVan(@RequestBody String requestOBJ,
+ @RequestHeader(value = "Authorization") String Authorization) {
+ OutputResponse response = new OutputResponse();
+ try {
+ String s = diagnosticDocumentIngestService.ingestDocuments(requestOBJ);
+ if (s != null)
+ response.setResponse(s);
+ else
+ response.setError(5000, "diagnostic document ingest failed");
+ } catch (Exception e) {
+ response.setError(e);
+ logger.error("Diagnostic document ingest Exception" + e);
+ }
+ return response.toString();
+ }
+
@Operation(summary = "Download data from server-to-van")
@PostMapping(value = { "/server-to-van" }, consumes = "application/json", produces = "application/json")
public String dataDownloadFromServer(@RequestBody SyncDownloadMaster syncDownloadMaster,
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 5302188d..fa3bd7f5 100644
--- a/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java
+++ b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java
@@ -146,8 +146,10 @@ public record NikshayRawRow(Long benRegId, String firstName, String middleLastNa
+ " d.incomeStatus AS socioeconomicStatus, "
+ " (SELECT o.chief_complaint FROM tb_stoptb_general_opd o WHERE o.ben_reg_id = m.BenRegId "
+ " 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 = m.BenRegId "
- + " AND ge.deleted = 0 ORDER BY ge.id DESC LIMIT 1) AS hivStatus, "
+ // hiv_status used to live on tb_stoptb_general_examination too, but that copy was
+ // dropped (AMRIT-DB V109) once tb_screening became the single source of truth.
+ + " (SELECT s.hiv_status FROM tb_screening s WHERE s.ben_reg_id = m.BenRegId "
+ + " AND (s.deleted = 0 OR s.deleted IS NULL) ORDER BY s.id DESC LIMIT 1) AS hivStatus, "
+ " d.IsHIVPositive AS isHivPos, "
+ " d.nikshayId AS existingNikshayId "
+ "FROM db_identity.i_beneficiarymapping m "
diff --git a/src/main/java/com/iemr/mmu/repo/syncActivity_syncLayer/SyncUtilityClassRepo.java b/src/main/java/com/iemr/mmu/repo/syncActivity_syncLayer/SyncUtilityClassRepo.java
index c146a7a9..dd054c41 100644
--- a/src/main/java/com/iemr/mmu/repo/syncActivity_syncLayer/SyncUtilityClassRepo.java
+++ b/src/main/java/com/iemr/mmu/repo/syncActivity_syncLayer/SyncUtilityClassRepo.java
@@ -31,4 +31,6 @@
@Repository
public interface SyncUtilityClassRepo extends CrudRepository {
List findBySyncTableGroupIDAndDeletedOrderBySyncTableDetailID(Integer groupID, Boolean deleted);
+
+ List findByDeletedOrderBySyncTableDetailID(Boolean deleted);
}
diff --git a/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java b/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java
new file mode 100644
index 00000000..56b78f16
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java
@@ -0,0 +1,372 @@
+/*
+* 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.benid;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import com.iemr.mmu.utils.RestTemplateUtil;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+
+/**
+ * Keeps the local BenID pool filled without anyone clicking "Generate BenID".
+ *
+ * Runs only on StopTB laptops (stoptb.enforce.vanid=true and stoptb.van.id>0).
+ * Pool empty -> bootstrap size; pool below lower limit -> batch size. IDs
+ * received from central are written to a pending file before the local import,
+ * so a failed import is retried with the same IDs instead of reserving new ones.
+ */
+@Service
+public class BenIdAutoTopUpService {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
+
+ /** Shared with the manual "Generate BenID" button so both never run together. */
+ public static final ReentrantLock GENERATION_LOCK = new ReentrantLock();
+
+ private static final String PENDING_FILE_NAME = "benid-pending-import.json";
+ private static final long TOKEN_KEEP_ALIVE_MS = 24 * 60 * 60_000L;
+
+ @Autowired
+ private CentralAuthService centralAuthService;
+
+ @Value("${benGenUrlCentral:}")
+ private String benGenUrlCentral;
+
+ @Value("${benImportUrlLocal:}")
+ private String benImportUrlLocal;
+
+ @Value("${benCheckUrlLocal:}")
+ private String benCheckUrlLocal;
+
+ @Value("${benid.pool.lowerLimit:2000}")
+ private long lowerLimit;
+
+ @Value("${benid.pool.batchSize:1000}")
+ private long batchSize;
+
+ @Value("${benid.pool.bootstrapSize:10000}")
+ private long bootstrapSize;
+
+ // central is asked for at most this many IDs per request: a big request can outlast the
+ // central gateway timeout, and a reply lost after central created the IDs wastes them
+ @Value("${benid.pool.maxPerRequest:1000}")
+ private long maxPerRequest;
+
+ @Value("${benid.token.dir:/opt/amrit/data}")
+ private String dataDir;
+
+ // wait after the 1st, 2nd, 3rd and later failures (5, 15, 30, 60 minutes)
+ @Value("${benid.autoTopUp.retryDelaysMs:300000,900000,1800000,3600000}")
+ private long[] retryDelaysMs;
+
+ @Value("${benid.http.connectTimeoutMs:15000}")
+ private int connectTimeoutMs;
+
+ @Value("${benid.http.readTimeoutMs:60000}")
+ private int readTimeoutMs;
+
+ private RestTemplate restTemplate;
+
+ private volatile int failureCount;
+ private volatile long nextAttemptAt;
+ private volatile long lastPoolCount = -1;
+ private volatile long lastTopUpAt;
+ private volatile long lastTopUpCount;
+ private volatile String lastError;
+ private volatile int pendingImportFailures;
+
+ // one background thread for this feature, so network waits never hold shared pool threads
+ private final ExecutorService background = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "benid-auto-topup");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private static final int MAX_PENDING_IMPORT_FAILURES = 3;
+
+ @PostConstruct
+ public void init() {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(connectTimeoutMs);
+ factory.setReadTimeout(readTimeoutMs);
+ restTemplate = new RestTemplate(factory);
+ }
+
+ @PreDestroy
+ public void shutdown() {
+ background.shutdownNow();
+ }
+
+ /** Runs a task on this feature's own background thread. */
+ public void runInBackground(Runnable task) {
+ background.submit(task);
+ }
+
+ /** Regular check; does nothing on central or non-StopTB deployments. */
+ @Scheduled(initialDelayString = "${benid.autoTopUp.initialDelayMs:120000}", fixedDelayString = "${benid.autoTopUp.checkIntervalMs:300000}")
+ public void scheduledCheck() {
+ runOnce(false);
+ }
+
+ /** Run straight away (after a login or a Data Sync), in the background. */
+ public void triggerNow() {
+ if (centralAuthService.isEnabled()) {
+ background.submit(() -> runOnce(true));
+ }
+ }
+
+ private void runOnce(boolean ignoreBackoff) {
+ if (!centralAuthService.isEnabled()) {
+ return;
+ }
+ if (!ignoreBackoff && System.currentTimeMillis() < nextAttemptAt) {
+ return;
+ }
+ if (!GENERATION_LOCK.tryLock()) {
+ logger.info("BenID generation already running - skipping this check");
+ return;
+ }
+ try {
+ // IDs already received but not saved locally are imported first
+ if (importPendingIfAny()) {
+ onSuccess();
+ }
+
+ long available = countLocalPool();
+ lastPoolCount = available;
+ long needed = available <= 0 ? bootstrapSize : (available < lowerLimit ? batchSize : 0);
+ if (needed == 0) {
+ centralAuthService.keepAliveIfDue(TOKEN_KEEP_ALIVE_MS);
+ onSuccess();
+ return;
+ }
+
+ String key = centralAuthService.getCentralSessionKey();
+ if (key == null) {
+ lastError = "No central connection - waiting for a user login";
+ logger.warn("BenID pool low ({} for vanID {}) but no central token - waiting for a user login",
+ available, centralAuthService.getVanID());
+ return;
+ }
+
+ int imported = 0;
+ for (long remaining = needed; remaining > 0;) {
+ long chunk = Math.min(remaining, Math.max(1, maxPerRequest));
+ imported += generateAndImport(centralAuthService.getVanID(), chunk, key, key, "datasync");
+ remaining -= chunk;
+ }
+ lastPoolCount = countLocalPool();
+ lastTopUpAt = System.currentTimeMillis();
+ lastTopUpCount = imported;
+ logger.info("BenID auto top-up: vanID {} +{} IDs, pool now {}", centralAuthService.getVanID(), imported,
+ lastPoolCount);
+ onSuccess();
+ } catch (Exception e) {
+ onFailure(e);
+ } finally {
+ GENERATION_LOCK.unlock();
+ }
+ }
+
+ /**
+ * Asks central for IDs for the van and imports them locally. The central
+ * response is written to the pending file first, so a failed local import
+ * does not lose IDs that central has already reserved for this van.
+ *
+ * @return number of IDs imported
+ */
+ public int generateAndImport(int vanID, long count, String centralAuthorization, String localAuthorization,
+ String localToken) throws Exception {
+ JSONObject request = new JSONObject();
+ request.put("vanID", vanID);
+ request.put("benIDRequired", count);
+
+ HttpEntity centralRequest = RestTemplateUtil.createRequestEntity(request.toString(),
+ centralAuthorization, "datasync");
+ logger.info("Requesting {} BenIDs from central for vanID {}", count, vanID);
+ long start = System.currentTimeMillis();
+ ResponseEntity response = restTemplate.exchange(benGenUrlCentral, HttpMethod.POST, centralRequest,
+ String.class);
+ logger.info("Central BenID generation answered in {} ms", System.currentTimeMillis() - start);
+ JSONObject obj = new JSONObject(response.getBody());
+ if (obj.optInt("statusCode") != 200 || !obj.has("data")) {
+ throw new IllegalStateException("Central BenID generation failed: " + obj.optString("errorMessage"));
+ }
+ JSONArray ids = obj.getJSONArray("data");
+ for (int k = 0; k < ids.length(); k++) {
+ ids.getJSONObject(k).put("vanID", vanID);
+ }
+ writePending(ids.toString());
+ long importStart = System.currentTimeMillis();
+ importLocally(ids.toString(), localAuthorization, localToken);
+ deletePending();
+ logger.info("Imported {} BenIDs locally in {} ms", ids.length(), System.currentTimeMillis() - importStart);
+ return ids.length();
+ }
+
+ private boolean importPendingIfAny() throws Exception {
+ Path pending = pendingPath();
+ if (!Files.exists(pending)) {
+ return false;
+ }
+ logger.info("Importing BenIDs left from an earlier run");
+ try {
+ String payload = new String(Files.readAllBytes(pending), StandardCharsets.UTF_8);
+ importLocally(payload, "datasync", "datasync");
+ } catch (Exception e) {
+ pendingImportFailures++;
+ if (pendingImportFailures < MAX_PENDING_IMPORT_FAILURES) {
+ throw e;
+ }
+ // a file that keeps failing (e.g. half-written at a power cut) must not stop top-ups
+ Path setAside = Paths.get(dataDir, "benid-pending-import.failed-" + System.currentTimeMillis() + ".json");
+ Files.move(pending, setAside, StandardCopyOption.REPLACE_EXISTING);
+ pendingImportFailures = 0;
+ logger.warn("Pending BenID file could not be imported {} times; moved to {} and continuing: {}",
+ MAX_PENDING_IMPORT_FAILURES, setAside, e.getMessage());
+ return false;
+ }
+ pendingImportFailures = 0;
+ deletePending();
+ return true;
+ }
+
+ private void importLocally(String payload, String authorization, String token) {
+ HttpEntity localRequest = RestTemplateUtil.createRequestEntity(payload, authorization, token);
+ ResponseEntity response = restTemplate.exchange(benImportUrlLocal, HttpMethod.POST, localRequest,
+ String.class);
+ JSONObject obj = new JSONObject(response.getBody());
+ if (obj.optInt("statusCode") != 200) {
+ throw new IllegalStateException("Local BenID import failed: " + obj.optString("errorMessage"));
+ }
+ }
+
+ public long countLocalPool() {
+ HttpEntity request = RestTemplateUtil.createRequestEntity(null, "datasync", "datasync");
+ ResponseEntity response = restTemplate.exchange(benCheckUrlLocal, HttpMethod.GET, request,
+ String.class);
+ JSONObject obj = new JSONObject(response.getBody());
+ if (obj.optInt("statusCode") != 200) {
+ throw new IllegalStateException("Local BenID count failed: " + obj.optString("errorMessage"));
+ }
+ Object data = obj.get("data");
+ JSONObject dataObj = data instanceof JSONObject ? (JSONObject) data : new JSONObject(data.toString());
+ return Long.parseLong(dataObj.get("response").toString());
+ }
+
+ private void onSuccess() {
+ failureCount = 0;
+ nextAttemptAt = 0;
+ lastError = null;
+ }
+
+ private void onFailure(Exception e) {
+ long base = retryDelaysMs[Math.min(failureCount, retryDelaysMs.length - 1)];
+ // +/-20% so that many laptops do not all retry at the same moment after an outage
+ long delay = base <= 0 ? 0 : (long) (base * ThreadLocalRandom.current().nextDouble(0.8, 1.2));
+ failureCount++;
+ nextAttemptAt = System.currentTimeMillis() + delay;
+ lastError = e.getMessage();
+ logger.warn("BenID auto top-up failed (attempt {}), next try in {} s: {}", failureCount, delay / 1000,
+ e.getMessage());
+ }
+
+ private Path pendingPath() {
+ return Paths.get(dataDir, PENDING_FILE_NAME);
+ }
+
+ private void writePending(String payload) {
+ try {
+ Files.createDirectories(Paths.get(dataDir));
+ // write a temp file and swap it in, so a power cut never leaves a half-written file
+ Path tmp = Paths.get(dataDir, PENDING_FILE_NAME + ".tmp");
+ Files.write(tmp, payload.getBytes(StandardCharsets.UTF_8));
+ try {
+ Files.move(tmp, pendingPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ } catch (java.nio.file.AtomicMoveNotSupportedException e) {
+ Files.move(tmp, pendingPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (Exception e) {
+ logger.warn("Could not write pending BenID file: {}", e.getMessage());
+ }
+ }
+
+ private void deletePending() {
+ try {
+ Files.deleteIfExists(pendingPath());
+ } catch (Exception e) {
+ logger.warn("Could not delete pending BenID file: {}", e.getMessage());
+ }
+ }
+
+ // ---------------------------------------------------------------- status
+
+ public JSONObject status() {
+ JSONObject s = new JSONObject();
+ s.put("enabled", centralAuthService.isEnabled());
+ if (!centralAuthService.isEnabled()) {
+ return s;
+ }
+ s.put("vanID", centralAuthService.getVanID());
+ s.put("centralConnection", centralAuthService.getState().name());
+ s.put("connectedUser", centralAuthService.getConnectedUser());
+ try {
+ lastPoolCount = countLocalPool();
+ } catch (Exception e) {
+ logger.debug("Live BenID count not available: {}", e.getMessage());
+ }
+ s.put("poolCount", lastPoolCount);
+ s.put("lowerLimit", lowerLimit);
+ s.put("lastTopUpAt", lastTopUpAt);
+ s.put("lastTopUpCount", lastTopUpCount);
+ s.put("pendingImport", Files.exists(pendingPath()));
+ String error = lastError != null ? lastError : centralAuthService.getLastError();
+ if (error != null) {
+ s.put("lastError", error);
+ }
+ return s;
+ }
+}
diff --git a/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java b/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java
new file mode 100644
index 00000000..d065eb19
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java
@@ -0,0 +1,416 @@
+/*
+* 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.benid;
+
+import java.net.InetAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Base64;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.RestTemplate;
+
+import jakarta.annotation.PostConstruct;
+
+/**
+ * Keeps this laptop connected to the central server without storing any
+ * central password.
+ *
+ * The operator's normal laptop login is passed here once; it is used for a
+ * single central login as an API client, which returns a refresh token. Only
+ * that refresh token is kept (AES-GCM encrypted with a key tied to this
+ * machine). Each time a central session is needed the refresh token is
+ * exchanged for a new JWT + refresh token, and the JWT for a central session
+ * key.
+ */
+@Service
+public class CentralAuthService {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
+
+ public enum State {
+ DISABLED, NOT_CONNECTED, CONNECTED, RECONNECT_NEEDED, PASSWORD_MISMATCH
+ }
+
+ // Central refresh tokens are only issued to API (mobile) clients.
+ private static final String API_CLIENT_USER_AGENT = "okhttp/4.12.0";
+ private static final String TOKEN_FILE_NAME = "central-refresh-token";
+ // user name only (no password): a laptop password central rejected is not sent again,
+ // also after a restart, so the central account cannot get locked by repeated attempts
+ private static final String REJECTED_FILE_NAME = "central-login-rejected";
+
+ @Value("${stoptb.enforce.vanid:false}")
+ private boolean enforceVanID;
+
+ @Value("${stoptb.van.id:0}")
+ private int vanID;
+
+ @Value("${centralLoginUrl:}")
+ private String centralLoginUrl;
+
+ @Value("${centralRefreshTokenUrl:}")
+ private String centralRefreshTokenUrl;
+
+ @Value("${centralSessionUrl:}")
+ private String centralSessionUrl;
+
+ @Value("${benid.token.dir:/opt/amrit/data}")
+ private String tokenDir;
+
+ @Value("${benid.token.secret:amrit-mmu-benid}")
+ private String tokenSecret;
+
+ @Value("${benid.http.connectTimeoutMs:15000}")
+ private int connectTimeoutMs;
+
+ @Value("${benid.http.readTimeoutMs:60000}")
+ private int readTimeoutMs;
+
+ private RestTemplate restTemplate;
+
+ // in-memory copy; the file is only a durable backup of this value
+ private volatile String refreshToken;
+ private volatile State state = State.NOT_CONNECTED;
+ private volatile String lastError;
+ private volatile long lastRefreshAt;
+ private volatile String connectedUser;
+ private volatile String rejectedUser;
+
+ @PostConstruct
+ public void init() {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(connectTimeoutMs);
+ factory.setReadTimeout(readTimeoutMs);
+ restTemplate = new RestTemplate(factory);
+
+ if (!isEnabled()) {
+ state = State.DISABLED;
+ logger.info("BenID auto top-up disabled (stoptb.enforce.vanid={}, stoptb.van.id={})", enforceVanID,
+ vanID);
+ return;
+ }
+ refreshToken = readTokenFile();
+ rejectedUser = readRejectedUser();
+ state = refreshToken != null ? State.CONNECTED : State.NOT_CONNECTED;
+ logger.info("BenID auto top-up enabled for vanID {}; central token {}", vanID,
+ refreshToken != null ? "loaded" : "not present yet");
+ }
+
+ public boolean isEnabled() {
+ return enforceVanID && vanID > 0 && !centralLoginUrl.isEmpty();
+ }
+
+ public int getVanID() {
+ return vanID;
+ }
+
+ public boolean hasToken() {
+ return refreshToken != null;
+ }
+
+ public State getState() {
+ return state;
+ }
+
+ public String getLastError() {
+ return lastError;
+ }
+
+ public long getLastRefreshAt() {
+ return lastRefreshAt;
+ }
+
+ public String getConnectedUser() {
+ return connectedUser;
+ }
+
+ /**
+ * One central login with the operator's credentials (password already
+ * encrypted the way MMU-UI sends it to Common-API). Skipped when a working
+ * token already exists, so central is not hit on every laptop login. A
+ * rejected password is never retried, so the central account cannot get
+ * locked by this.
+ *
+ * @param force true when the user explicitly reconnects (e.g. Data Sync
+ * login), even if a token exists
+ * @return true if a new token was saved
+ */
+ public synchronized boolean capture(String userName, String encryptedPassword, boolean force) {
+ if (!isEnabled() || userName == null || encryptedPassword == null) {
+ return false;
+ }
+ if (!force && refreshToken != null && state == State.CONNECTED) {
+ return false;
+ }
+ // a laptop password central already rejected is not sent again (account lock risk);
+ // only a Data Sync login (force, real central password) tries again
+ if (!force && userName.trim().equalsIgnoreCase(rejectedUser)) {
+ logger.info("Central login skipped for user {}: password was rejected before", userName);
+ return false;
+ }
+ try {
+ JSONObject body = new JSONObject();
+ body.put("userName", userName.trim());
+ body.put("password", encryptedPassword);
+ body.put("doLogout", false);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set(HttpHeaders.USER_AGENT, API_CLIENT_USER_AGENT);
+
+ ResponseEntity response = restTemplate.exchange(centralLoginUrl, HttpMethod.POST,
+ new HttpEntity<>(body.toString(), headers), String.class);
+ JSONObject obj = new JSONObject(response.getBody());
+ if (obj.optInt("statusCode") == 200 && obj.has("data")
+ && obj.getJSONObject("data").has("refreshToken")) {
+ saveToken(obj.getJSONObject("data").getString("refreshToken"));
+ connectedUser = userName.trim();
+ rejectedUser = null;
+ deleteRejectedUser();
+ state = State.CONNECTED;
+ lastError = null;
+ logger.info("Central token captured for user {}", connectedUser);
+ return true;
+ }
+ // wrong username/password or account problem: do not retry with these credentials
+ lastError = obj.optString("errorMessage", "Central login rejected");
+ rejectedUser = userName.trim();
+ saveRejectedUser(rejectedUser);
+ state = State.PASSWORD_MISMATCH;
+ logger.warn("Central login rejected for user {}: {}", userName, lastError);
+ } catch (Exception e) {
+ // network problem: nothing saved, next login tries again
+ lastError = "Central server not reachable: " + e.getMessage();
+ logger.warn("Central login not possible right now: {}", e.getMessage());
+ }
+ return false;
+ }
+
+ /**
+ * Returns a fresh central session key (refreshToken -> JWT -> session key),
+ * or null if this laptop has no usable token. Throws on network errors so
+ * callers can retry later.
+ */
+ public synchronized String getCentralSessionKey() throws Exception {
+ if (!isEnabled() || refreshToken == null) {
+ return null;
+ }
+ String jwt;
+ long start = System.currentTimeMillis();
+ try {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ JSONObject body = new JSONObject();
+ body.put("refreshToken", refreshToken);
+ ResponseEntity response = restTemplate.exchange(centralRefreshTokenUrl, HttpMethod.POST,
+ new HttpEntity<>(body.toString(), headers), String.class);
+ JSONObject obj = new JSONObject(response.getBody());
+ jwt = obj.getString("jwtToken");
+ if (obj.has("refreshToken")) {
+ saveToken(obj.getString("refreshToken"));
+ }
+ lastRefreshAt = System.currentTimeMillis();
+ logger.info("Central token refreshed in {} ms", lastRefreshAt - start);
+ } catch (HttpStatusCodeException e) {
+ if (e.getStatusCode().value() == 401) {
+ // expired, revoked or user deactivated on central
+ logger.warn("Central refresh token rejected ({}); a new login is needed",
+ e.getResponseBodyAsString());
+ deleteToken();
+ state = State.RECONNECT_NEEDED;
+ lastError = "Central connection expired, please log in again";
+ return null;
+ }
+ throw e;
+ }
+
+ // JWT -> central session key; Common-API creates a new Redis session for it
+ long sessionStart = System.currentTimeMillis();
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("Jwttoken", jwt);
+ ResponseEntity response = restTemplate.exchange(centralSessionUrl, HttpMethod.POST,
+ new HttpEntity<>("{}", headers), String.class);
+ JSONObject obj = new JSONObject(response.getBody());
+ if (obj.optInt("statusCode") != 200 || !obj.has("data")) {
+ throw new IllegalStateException("Central session not created: " + obj.optString("errorMessage"));
+ }
+ Object data = obj.get("data");
+ JSONObject dataObj = data instanceof JSONObject ? (JSONObject) data : new JSONObject(data.toString());
+ state = State.CONNECTED;
+ lastError = null;
+ logger.info("Central session created in {} ms", System.currentTimeMillis() - sessionStart);
+ return dataObj.getString("key");
+ }
+
+ /** Keeps the 90-day token alive even when no IDs are needed. */
+ public void keepAliveIfDue(long maxAgeMs) {
+ if (refreshToken != null && System.currentTimeMillis() - lastRefreshAt > maxAgeMs) {
+ try {
+ getCentralSessionKey();
+ } catch (Exception e) {
+ logger.debug("Token keep-alive skipped: {}", e.getMessage());
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------- token file
+
+ private Path tokenPath() {
+ return Paths.get(tokenDir, TOKEN_FILE_NAME);
+ }
+
+ private void saveToken(String token) {
+ refreshToken = token;
+ try {
+ Path dir = Paths.get(tokenDir);
+ Files.createDirectories(dir);
+ setPermissions(dir, "rwx------");
+ Path tmp = dir.resolve(TOKEN_FILE_NAME + ".tmp");
+ Files.write(tmp, encrypt(token).getBytes(StandardCharsets.UTF_8));
+ setPermissions(tmp, "rw-------");
+ try {
+ Files.move(tmp, tokenPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(tmp, tokenPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (Exception e) {
+ // keep working from memory; the token is captured again after a restart
+ logger.warn("Could not save central token to {} (kept in memory only): {}", tokenDir, e.getMessage());
+ }
+ }
+
+ private String readTokenFile() {
+ try {
+ Path path = tokenPath();
+ if (Files.exists(path)) {
+ return decrypt(new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim());
+ }
+ } catch (Exception e) {
+ logger.warn("Stored central token could not be read, a new login is needed: {}", e.getMessage());
+ }
+ return null;
+ }
+
+ private void deleteToken() {
+ refreshToken = null;
+ try {
+ Files.deleteIfExists(tokenPath());
+ } catch (Exception e) {
+ logger.warn("Could not delete central token file: {}", e.getMessage());
+ }
+ }
+
+ private String readRejectedUser() {
+ try {
+ Path path = Paths.get(tokenDir, REJECTED_FILE_NAME);
+ if (Files.exists(path)) {
+ String user = new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim();
+ return user.isEmpty() ? null : user;
+ }
+ } catch (Exception e) {
+ logger.warn("Could not read rejected-login marker: {}", e.getMessage());
+ }
+ return null;
+ }
+
+ private void saveRejectedUser(String user) {
+ try {
+ Path dir = Paths.get(tokenDir);
+ Files.createDirectories(dir);
+ Path path = dir.resolve(REJECTED_FILE_NAME);
+ Files.write(path, user.getBytes(StandardCharsets.UTF_8));
+ setPermissions(path, "rw-------");
+ } catch (Exception e) {
+ logger.warn("Could not save rejected-login marker: {}", e.getMessage());
+ }
+ }
+
+ private void deleteRejectedUser() {
+ try {
+ Files.deleteIfExists(Paths.get(tokenDir, REJECTED_FILE_NAME));
+ } catch (Exception e) {
+ logger.warn("Could not delete rejected-login marker: {}", e.getMessage());
+ }
+ }
+
+ private static void setPermissions(Path path, String perms) {
+ try {
+ Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(perms));
+ } catch (Exception e) {
+ // non-POSIX file system; nothing more we can do
+ }
+ }
+
+ private SecretKeySpec machineKey() throws Exception {
+ String machineId;
+ try {
+ machineId = new String(Files.readAllBytes(Paths.get("/etc/machine-id")), StandardCharsets.UTF_8).trim();
+ } catch (Exception e) {
+ machineId = InetAddress.getLocalHost().getHostName();
+ }
+ byte[] key = MessageDigest.getInstance("SHA-256")
+ .digest((machineId + ":" + tokenSecret).getBytes(StandardCharsets.UTF_8));
+ return new SecretKeySpec(key, "AES");
+ }
+
+ private String encrypt(String plain) throws Exception {
+ byte[] iv = new byte[12];
+ new SecureRandom().nextBytes(iv);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, machineKey(), new GCMParameterSpec(128, iv));
+ byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
+ byte[] out = new byte[iv.length + enc.length];
+ System.arraycopy(iv, 0, out, 0, iv.length);
+ System.arraycopy(enc, 0, out, iv.length, enc.length);
+ return Base64.getEncoder().encodeToString(out);
+ }
+
+ private String decrypt(String encoded) throws Exception {
+ byte[] in = Base64.getDecoder().decode(encoded);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, machineKey(), new GCMParameterSpec(128, in, 0, 12));
+ return new String(cipher.doFinal(in, 12, in.length - 12), StandardCharsets.UTF_8);
+ }
+}
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncService.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncService.java
new file mode 100644
index 00000000..e8971fe7
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncService.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.dataSyncActivity;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import com.iemr.mmu.service.benid.BenIdAutoTopUpService;
+import com.iemr.mmu.service.benid.CentralAuthService;
+
+import jakarta.annotation.PreDestroy;
+
+/**
+ * StopTB laptops: runs Data Sync by itself, so central stays current and laptops get master changes without
+ * anyone pressing Upload or Download Masters.
+ *
+ * - Upload (van to central) every sync.autoUpload.intervalMs (default 30 min).
+ * - Master download (central to van) every sync.autoMasterDownload.intervalMs (default 6 h).
+ *
+ * Uses the central connection of the automatic BenID download (CentralAuthService): no password is stored, and
+ * SyncedBy is the operator who connected the laptop. Active only where CentralAuthService is enabled
+ * (stoptb.enforce.vanid=true and stoptb.van.id>0); central and other deployments are unaffected. The manual
+ * buttons keep working as before.
+ *
+ * Off by default (manual sync only) until the automatic schedule is approved: set sync.autoUpload.enabled,
+ * sync.autoMasterDownload.enabled and sync.autoInventoryDownload.enabled to true to switch it on.
+ */
+@Service
+public class AutoSyncService {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
+
+ /** Upload and master download never run at the same time from here. */
+ private static final ReentrantLock SYNC_LOCK = new ReentrantLock();
+
+ @Autowired
+ private CentralAuthService centralAuthService;
+
+ @Autowired
+ private UploadDataToServerImpl uploadDataToServerImpl;
+
+ @Autowired
+ private DownloadDataFromServerImpl downloadDataFromServerImpl;
+
+ @Autowired
+ private DownloadDataFromServerTransactionalImpl downloadDataFromServerTransactional;
+
+ @Autowired
+ private DataSyncRepository dataSyncRepository;
+
+ @Autowired
+ private BenIdAutoTopUpService benIdAutoTopUpService;
+
+ @Value("${sync.autoUpload.enabled:false}")
+ private boolean uploadEnabled;
+
+ @Value("${sync.autoMasterDownload.enabled:false}")
+ private boolean masterDownloadEnabled;
+
+ // refills / stock issued at central for this van's store ("Inventory Data Download"), after each upload
+ @Value("${sync.autoInventoryDownload.enabled:false}")
+ private boolean inventoryDownloadEnabled;
+
+ // automatic master download fetches only the inventory masters (items, stores, item->store mapping, Min Stock);
+ // other masters (users, complaints, villages) come with the manual Download Masters button
+ @Value("${sync.autoMasterDownload.inventoryOnly:true}")
+ private boolean masterDownloadInventoryOnly;
+
+ static final Set INVENTORY_MASTERS = Set.of("m_item", "m_itemcategory", "m_itemissueconfig", "m_uom",
+ "m_manufacturer", "m_pharmacologicalcategory", "m_supplier", "m_facilitytype", "m_facility",
+ "m_itemfacilitymapping");
+
+ private volatile long lastUploadAt;
+ private volatile String lastUploadResult;
+ private volatile long lastMasterDownloadAt;
+ private volatile String lastMasterDownloadResult;
+ private volatile long lastInventoryDownloadAt;
+ private volatile String lastInventoryDownloadResult;
+ private volatile String lastError;
+
+ // own thread: a long upload must not hold the shared scheduler thread
+ private final ExecutorService background = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "auto-data-sync");
+ t.setDaemon(true);
+ return t;
+ });
+
+ @PreDestroy
+ public void shutdown() {
+ background.shutdownNow();
+ }
+
+ @Scheduled(initialDelayString = "${sync.autoUpload.initialDelayMs:600000}", fixedDelayString = "${sync.autoUpload.intervalMs:1800000}")
+ public void scheduledUpload() {
+ if (uploadEnabled && centralAuthService.isEnabled()) {
+ background.submit(this::uploadOnce);
+ }
+ }
+
+ @Scheduled(initialDelayString = "${sync.autoMasterDownload.initialDelayMs:900000}", fixedDelayString = "${sync.autoMasterDownload.intervalMs:21600000}")
+ public void scheduledMasterDownload() {
+ if (masterDownloadEnabled && centralAuthService.isEnabled()) {
+ background.submit(this::masterDownloadOnce);
+ }
+ }
+
+ void uploadOnce() {
+ if (!SYNC_LOCK.tryLock()) {
+ return;
+ }
+ try {
+ String key = centralAuthService.getCentralSessionKey();
+ if (key == null) {
+ lastError = "No central connection - waiting for a user login";
+ return;
+ }
+ int vanID = centralAuthService.getVanID();
+ String user = centralAuthService.getConnectedUser();
+ String result = uploadDataToServerImpl.getDataToSyncToServer(vanID,
+ user != null && !user.isBlank() ? user : "auto-sync", key, null);
+ lastUploadAt = System.currentTimeMillis();
+ lastUploadResult = result;
+ lastError = null;
+ logger.info("Automatic upload for vanID {} finished: {}", vanID, result);
+ // internet is up: top up the BenID pool if it is low (same as the manual upload)
+ benIdAutoTopUpService.triggerNow();
+ if (inventoryDownloadEnabled)
+ inventoryDownload(vanID, key);
+ } catch (Exception e) {
+ lastError = "Upload: " + e.getMessage();
+ logger.warn("Automatic upload failed: {}", e.getMessage());
+ } finally {
+ SYNC_LOCK.unlock();
+ }
+ }
+
+ // Same as the "Inventory Data Download" button: indents, indent issues, stock transfers and stock entries
+ // that central has for this van's store. Its own failure does not undo the upload.
+ private void inventoryDownload(int vanID, String key) {
+ try {
+ int done = downloadDataFromServerTransactional.downloadTransactionalData(vanID, key, "datasync");
+ lastInventoryDownloadAt = System.currentTimeMillis();
+ lastInventoryDownloadResult = done > 0 ? "success" : "nothing downloaded";
+ logger.info("Automatic inventory download for vanID {} finished: {}", vanID, lastInventoryDownloadResult);
+ } catch (Exception e) {
+ lastError = "Inventory download: " + e.getMessage();
+ logger.warn("Automatic inventory download failed: {}", e.getMessage());
+ }
+ }
+
+ void masterDownloadOnce() {
+ if (!SYNC_LOCK.tryLock()) {
+ return;
+ }
+ try {
+ String key = centralAuthService.getCentralSessionKey();
+ if (key == null) {
+ lastError = "No central connection - waiting for a user login";
+ return;
+ }
+ int vanID = centralAuthService.getVanID();
+ Integer psm = dataSyncRepository.getVanProviderServiceMapID(vanID);
+ if (psm == null) {
+ lastError = "Master download: van " + vanID + " not found in m_van";
+ return;
+ }
+ String result = downloadDataFromServerImpl.downloadMasterDataFromServer(key, null, vanID, psm,
+ masterDownloadInventoryOnly ? INVENTORY_MASTERS : null);
+ if ("inProgress".equalsIgnoreCase(result)) {
+ logger.info("Master download already running (manual) - skipping the automatic one");
+ return;
+ }
+ lastMasterDownloadAt = System.currentTimeMillis();
+ lastMasterDownloadResult = result;
+ lastError = null;
+ logger.info("Automatic master download for vanID {} finished: {}", vanID, result);
+ } catch (Exception e) {
+ lastError = "Master download: " + e.getMessage();
+ logger.warn("Automatic master download failed: {}", e.getMessage());
+ } finally {
+ SYNC_LOCK.unlock();
+ }
+ }
+
+ /** For the Data Sync screen: last runs and the rows central has rejected. */
+ public JSONObject status() {
+ JSONObject s = new JSONObject();
+ s.put("enabled", centralAuthService.isEnabled());
+ s.put("uploadEnabled", uploadEnabled);
+ s.put("masterDownloadEnabled", masterDownloadEnabled);
+ s.put("lastUploadAt", lastUploadAt);
+ s.put("lastUploadResult", lastUploadResult == null ? JSONObject.NULL : lastUploadResult);
+ s.put("lastMasterDownloadAt", lastMasterDownloadAt);
+ s.put("lastMasterDownloadResult",
+ lastMasterDownloadResult == null ? JSONObject.NULL : lastMasterDownloadResult);
+ s.put("inventoryDownloadEnabled", inventoryDownloadEnabled);
+ s.put("lastInventoryDownloadAt", lastInventoryDownloadAt);
+ s.put("lastInventoryDownloadResult",
+ lastInventoryDownloadResult == null ? JSONObject.NULL : lastInventoryDownloadResult);
+ s.put("lastError", lastError == null ? JSONObject.NULL : lastError);
+ List> failures = dataSyncRepository.getSyncFailures();
+ long failedRows = failures.stream().mapToLong(f -> ((Number) f.get("failedRows")).longValue()).sum();
+ s.put("failedRows", failedRows);
+ s.put("failures", new JSONArray(failures));
+ return s;
+ }
+}
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DataSyncRepository.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DataSyncRepository.java
index 75a74842..99b4ec04 100644
--- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DataSyncRepository.java
+++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DataSyncRepository.java
@@ -23,9 +23,11 @@
import java.sql.Timestamp;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.sql.PreparedStatement;
@@ -40,6 +42,7 @@
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
+import com.iemr.mmu.data.syncActivity_syncLayer.SyncUtilityClass;
import com.iemr.mmu.repo.syncActivity_syncLayer.SyncUtilityClassRepo;
import com.iemr.mmu.utils.validator.SqlIdentifierValidator;
@@ -127,9 +130,103 @@ public int updateProcessedFlagInVan(String schemaName, String tableName, List psm = getJdbcTemplate().queryForList(
+ "SELECT ProviderServiceMapID FROM db_iemr.m_van WHERE VanID = ? AND Deleted = false", Integer.class,
+ vanID);
+ return psm.isEmpty() ? null : psm.get(0);
+ }
+
+ /**
+ * Rows central rejected on upload (Processed = 'F'), per sync table and reason. They are sent again on every
+ * upload until central accepts them.
+ */
+ public List> getSyncFailures() {
+ jdbcTemplate = getJdbcTemplate();
+ List> result = new ArrayList<>();
+ Set seen = new HashSet<>();
+ for (SyncUtilityClass table : syncutilityClassRepo.findByDeletedOrderBySyncTableDetailID(false)) {
+ if (Boolean.TRUE.equals(table.getIsMaster()) || table.getSchemaName() == null
+ || table.getTableName() == null)
+ continue;
+ String qualified = SqlIdentifierValidator.validatedSchemaName(table.getSchemaName()) + "."
+ + SqlIdentifierValidator.validatedTableName(table.getTableName());
+ if (!seen.add(qualified.toLowerCase()))
+ continue;
+ try {
+ List> rows = jdbcTemplate.queryForList("SELECT '" + table.getTableName()
+ + "' AS tableName, SyncFailureReason AS reason, COUNT(*) AS failedRows, DATE_FORMAT(MAX(SyncedDate), '%d-%m-%Y %H:%i') AS lastTried "
+ + "FROM " + qualified + " WHERE Processed = 'F' GROUP BY SyncFailureReason");
+ result.addAll(rows);
+ } catch (Exception e) {
+ logger.warn("Sync failure check skipped for {}: {}", qualified, e.getMessage());
+ }
+ }
+ return result;
+ }
+
// ---------------------------------- End of Upload repository
// ---------------------------------- Download Repository
+
+ /**
+ * Down-sync copies the beneficiary tables but not i_ben_flow_outreach, and the tab download
+ * (FLW-API getBeneficiaryData) only serves beneficiaries that have a worklist row. So a
+ * beneficiary registered elsewhere and down-synced here never reached this laptop's tabs.
+ * Creates the missing worklist row for this van's beneficiaries, filled as TM-API does at
+ * registration. Processed = 'P' so it is not uploaded back: central already has its own row.
+ */
+ public int createMissingWorklistRows(int vanID, int providerServiceMapID) {
+ jdbcTemplate = getJdbcTemplate();
+
+ String village = "(SELECT h.%s FROM db_identity.i_beneficiarydetails_rmnch r "
+ + " JOIN db_identity.i_householddetails h ON h.houseoldId = r.houseoldId "
+ + " WHERE r.BeneficiaryRegID = bm.BenRegId LIMIT 1)";
+
+ String insert = "INSERT INTO db_iemr.i_ben_flow_outreach "
+ + " (beneficiary_reg_id, beneficiary_id, visit_no, ben_name, ben_age, ben_dob, ben_gender, "
+ + " ben_gender_val, ben_age_val, ben_phone_no, father_name, spouse_name, nurse_flag, doctor_flag, "
+ + " pharmacist_flag, doctor_signature_flag, BenArrivedFlag, created_by, created_date, modified_by, "
+ + " modified_date, registrationDate, districtID, district, villageID, village, "
+ + " providerServiceMapID, vanID, Processed, deleted) "
+ + " SELECT * FROM ( SELECT bm.BenRegId, rg.BeneficiaryID, 1 AS visit_no, "
+ + " TRIM(CONCAT_WS(' ', d.FirstName, NULLIF(d.MiddleName, ''), d.LastName)) AS ben_name, "
+ + " CONCAT(TIMESTAMPDIFF(YEAR, d.DOB, bm.CreatedDate), ' years - ', "
+ + " MOD(TIMESTAMPDIFF(MONTH, d.DOB, bm.CreatedDate), 12), ' months') AS ben_age, "
+ + " d.DOB, d.Gender, d.GenderId, TIMESTAMPDIFF(YEAR, d.DOB, bm.CreatedDate) AS ben_age_val, "
+ + " COALESCE(c.PreferredPhoneNum, c.PhoneNum1) AS phone, "
+ + " COALESCE(d.FatherName, '') AS father, COALESCE(d.SpouseName, '') AS spouse, "
+ + " 1 AS nf, 0 AS df, 0 AS pf, 0 AS dsf, 0 AS baf, bm.CreatedBy, bm.CreatedDate AS cdate, "
+ + " 'downsync' AS mby, NOW() AS mdate, bm.CreatedDate AS rdate, "
+ + " COALESCE(a.CurrDistrictId, a.PermDistrictId) AS districtID, "
+ + " COALESCE(a.CurrDistrict, a.PermDistrict) AS district, "
+ + " COALESCE(" + String.format(village, "villageid") + ", a.CurrVillageId, a.PermVillageId) AS villageID, "
+ + " COALESCE(" + String.format(village, "village") + ", a.CurrVillage, a.PermVillage) AS village, "
+ + " ? AS psm, bm.VanID, 'P' AS processed, 0 AS deleted "
+ + " FROM db_identity.i_beneficiarymapping bm "
+ + " JOIN db_identity.m_beneficiaryregidmapping rg ON rg.BenRegId = bm.BenRegId "
+ + " JOIN db_identity.i_beneficiarydetails d ON d.BeneficiaryDetailsId = bm.BenDetailsId "
+ + " LEFT JOIN db_identity.i_beneficiarycontacts c ON c.BenContactsID = bm.BenContactsId "
+ + " LEFT JOIN db_identity.i_beneficiaryaddress a ON a.BenAddressID = bm.BenAddressId "
+ + " WHERE bm.VanID = ? AND bm.Deleted = false "
+ // the van can hold beneficiaries of other service lines; only its own get a row
+ + " AND (SELECT r.ProviderServiceMapID FROM db_identity.i_beneficiarydetails_rmnch r "
+ + " WHERE r.BeneficiaryRegID = bm.BenRegId LIMIT 1) = ? "
+ + " AND NOT EXISTS (SELECT 1 FROM db_iemr.i_ben_flow_outreach f "
+ + " WHERE f.beneficiary_reg_id = bm.BenRegId) ) x "
+ + " WHERE x.villageID IS NOT NULL";
+
+ int inserted = jdbcTemplate.update(insert, providerServiceMapID, vanID, providerServiceMapID);
+
+ if (inserted > 0)
+ // same convention as TM-API: VanSerialNo is the row's own id
+ jdbcTemplate.update("UPDATE db_iemr.i_ben_flow_outreach SET VanSerialNo = ben_flow_id "
+ + " WHERE modified_by = 'downsync' AND VanSerialNo IS NULL");
+
+ return inserted;
+ }
+
public int[] updateLatestMasterInLocal(String query, List syncDataList) {
int[] i = null;
// get JDBC template
@@ -176,6 +273,17 @@ public String resolveLastModColumn(String schema, String table) {
return (found == null || found.isEmpty()) ? null : found.get(0);
}
+ public Set getTableColumns(String schema, String table) {
+ jdbcTemplate = getJdbcTemplate();
+
+ String query = " SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ";
+
+ Set columns = new HashSet<>();
+ for (String c : jdbcTemplate.queryForList(query, String.class, schema, table))
+ columns.add(c.toLowerCase());
+ return columns;
+ }
+
public List getDownSyncColumns(String schema, String table) {
jdbcTemplate = getJdbcTemplate();
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownSyncDataFromServerImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownSyncDataFromServerImpl.java
index c3c1e1d0..2c70854e 100644
--- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownSyncDataFromServerImpl.java
+++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownSyncDataFromServerImpl.java
@@ -163,6 +163,7 @@ public String startDownSync(String serverAuthorization, String jwtToken, Integer
}
progressCounter++;
}
+ createMissingWorklistRows(vanID, providerServiceMapID);
countOutstandingConflicts(downSyncTables, vanID);
} finally {
IN_PROGRESS.set(false);
@@ -207,6 +208,23 @@ public Map getDownSyncStatus() {
return resultMap;
}
+ /** Down-synced beneficiaries need a worklist row before this laptop's tabs can download them. */
+ private void createMissingWorklistRows(Integer vanID, Integer providerServiceMapID) {
+ try {
+ Integer psm = providerServiceMapID != null ? providerServiceMapID
+ : dataSyncRepository.getVanProviderServiceMapID(vanID);
+ if (psm == null) {
+ logger.warn("Worklist rows not created after down-sync: no service line for van {}", vanID);
+ return;
+ }
+ int created = dataSyncRepository.createMissingWorklistRows(vanID, psm);
+ logger.info("Created {} worklist rows for down-synced beneficiaries of van {}", created, vanID);
+ } catch (Exception e) {
+ // the down-sync itself already succeeded; a failure here must not undo it
+ logger.error("Creating worklist rows after down-sync failed for van " + vanID + " : " + e.getMessage(), e);
+ }
+ }
+
private void countOutstandingConflicts(List downSyncTables, Integer vanID) {
outstandingConflictCounter = 0;
outstandingConflictTables.clear();
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java
index 6a6d12bc..572724be 100644
--- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java
+++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java
@@ -27,9 +27,11 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import org.json.JSONObject;
import org.slf4j.Logger;
@@ -45,6 +47,7 @@
import com.google.gson.Gson;
import com.iemr.mmu.data.syncActivity_syncLayer.MasterDownloadDataDigester;
+import com.iemr.mmu.service.benid.BenIdAutoTopUpService;
import com.iemr.mmu.data.syncActivity_syncLayer.SyncDownloadMaster;
import com.iemr.mmu.data.syncActivity_syncLayer.TempVan;
import com.iemr.mmu.repo.syncActivity_syncLayer.SyncDownloadMasterRepo;
@@ -60,6 +63,9 @@ public class DownloadDataFromServerImpl implements DownloadDataFromServer {
// rest URLs from server to download master data for van
@Value("${dataSyncDownloadUrl}")
private String dataSyncDownloadUrl;
+ // StopTB camp laptop (laptops are set up from copied databases); other MMU deployments keep standard behaviour
+ @Value("${stoptb.enforce.vanid:false}")
+ private boolean stopTBLaptop;
@Autowired
private SyncDownloadMasterRepo syncDownloadMasterRepo;
@Autowired
@@ -95,6 +101,14 @@ public class DownloadDataFromServerImpl implements DownloadDataFromServer {
*/
public String downloadMasterDataFromServer(String ServerAuthorization, String jwtToken, Integer vanID, Integer psmID)
throws Exception {
+ return downloadMasterDataFromServer(ServerAuthorization, jwtToken, vanID, psmID, null);
+ }
+
+ /**
+ * @param onlyTables master tables to download (case-insensitive), or null for all of m_downloadmastertable
+ */
+ public String downloadMasterDataFromServer(String ServerAuthorization, String jwtToken, Integer vanID, Integer psmID,
+ Set onlyTables) throws Exception {
if (totalCounter != progressCounter) {
return "inProgress";
@@ -103,6 +117,10 @@ public String downloadMasterDataFromServer(String ServerAuthorization, String jw
String successFlag = " Master download started ";
ArrayList downloadMasterList = syncDownloadMasterRepo.getDownloadTables();
+ if (onlyTables != null && downloadMasterList != null) {
+ downloadMasterList.removeIf(t -> t.getTableName() == null
+ || !onlyTables.contains(t.getTableName().toLowerCase()));
+ }
totalCounter = downloadMasterList.size();
progressCounter = 0;
@@ -178,6 +196,8 @@ public String call() {
private int downloadDataFromServer(SyncDownloadMaster syncDownloadMaster,String ServerAuthorization, String jwtToken)
throws Exception {
int successFlag = 0;
+ if (stopTBLaptop)
+ fullDownloadForLineAndVanMasters(syncDownloadMaster);
// initializing RestTemplate
RestTemplate restTemplate = new RestTemplate();
// Provide the required second argument, e.g., an empty string or appropriate authorization token
@@ -210,6 +230,18 @@ private int downloadDataFromServer(SyncDownloadMaster syncDownloadMaster,String
return successFlag;
}
+ /**
+ * StopTB laptops only. Service-line (P) and van (V) masters - hubs, vans, service points, stores, items, item-store mapping, roles -
+ * are always downloaded in full for this van's line: they are small, and a laptop set up from a copied database
+ * can carry download dates newer than central rows it never received (missing hub -> its vans fail on the
+ * foreign key). Rows are inserted/updated by ID, so repeating them is harmless. Masters shared by all lines (A,
+ * e.g. 661k villages) stay incremental.
+ */
+ static void fullDownloadForLineAndVanMasters(SyncDownloadMaster table) {
+ if ("P".equalsIgnoreCase(table.getMasterType()) || "V".equalsIgnoreCase(table.getMasterType()))
+ table.setLastDownloadDate(null);
+ }
+
private int updateMastersWithLatestData(MasterDownloadDataDigester masterDownloadDataDigester,
SyncDownloadMaster syncDownloadMaster) {
int successFlag = 0;
@@ -326,6 +358,10 @@ public Map getDownloadStatus() {
public int callCentralAPIToGenerateBenIDAndimportToLocal(String requestOBJ, String Authorization,
String ServerAuthorization, String token) throws Exception {
int i = 0, i1 = 0;
+ // never run together with the automatic top-up, so the same IDs are not requested twice
+ if (!BenIdAutoTopUpService.GENERATION_LOCK.tryLock(2, TimeUnit.MINUTES)) {
+ throw new Exception("BenID generation is already running, please try again in a few minutes");
+ }
try{
JSONObject originalRequest = new JSONObject(requestOBJ);
BigInteger vanID = null;
@@ -378,6 +414,8 @@ public int callCentralAPIToGenerateBenIDAndimportToLocal(String requestOBJ, Stri
} catch (Exception e) {
logger.error("Error while generating catch UNIQUE_ID at central server: " + e.getMessage());
throw new Exception("Error while generating catch UNIQUE_ID at central server: " + e.getMessage());
+ } finally {
+ BenIdAutoTopUpService.GENERATION_LOCK.unlock();
}
return i;
}
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerTransactionalImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerTransactionalImpl.java
index ee3c39a5..e441ea6e 100644
--- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerTransactionalImpl.java
+++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerTransactionalImpl.java
@@ -227,8 +227,6 @@ public int downloadTransactionalData(int vanID, String ServerAuthorization, Stri
default:
}
-
- i++;
}
return 1;
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/UploadDataToServerImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/UploadDataToServerImpl.java
index d9567861..b0aa115d 100644
--- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/UploadDataToServerImpl.java
+++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/UploadDataToServerImpl.java
@@ -26,6 +26,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import org.json.JSONArray;
import org.json.JSONException;
@@ -80,6 +81,11 @@ public class UploadDataToServerImpl implements UploadDataToServer {
@Value("${BATCH_SIZE}")
private int BATCH_SIZE;
+ // StopTB camp laptop: a table configured in several sync groups is uploaded only by its last group (see
+ // skipTablesUploadedByLaterGroup)
+ @Value("${stoptb.enforce.vanid:false}")
+ private boolean stopTBLaptop;
+
@Autowired
private DataSyncRepository dataSyncRepository;
@Autowired
@@ -151,9 +157,33 @@ private String startDataSync(int vanID, String user, String Authorization, Strin
String tableKey = obj.getSchemaName() + "." + obj.getTableName();
boolean tableHasError = false;
- // get data from DB to sync to server
- List> syncData = getDataToSync(obj.getSchemaName(), obj.getTableName(),
- obj.getVanColumnName());
+ String[] columns = { obj.getVanColumnName(), obj.getServerColumnName() };
+ List> syncData;
+ try {
+ if (stopTBLaptop)
+ columns = useRenamedColumns(obj.getTableName(), columns[0], columns[1],
+ dataSyncRepository.getTableColumns(obj.getSchemaName(), obj.getTableName()));
+
+ // get data from DB to sync to server
+ syncData = getDataToSync(obj.getSchemaName(), obj.getTableName(), columns[0]);
+ } catch (Exception e) {
+ if (!stopTBLaptop)
+ throw e;
+ // StopTB camp laptop: one table that cannot be read must not stop the upload of the others;
+ // its rows stay unprocessed and go on the next upload
+ logger.error("Upload of table {} skipped: {}", tableKey, e.getMessage());
+ groupHasFailures = true;
+ Map tableDetails = new HashMap<>();
+ tableDetails.put("tableName", obj.getTableName());
+ tableDetails.put("schemaName", obj.getSchemaName());
+ tableDetails.put("status", "failed");
+ tableDetails.put("totalRecords", 0);
+ tableDetails.put("successfulRecords", 0);
+ tableDetails.put("failedRecords", 0);
+ tableDetails.put("failureReasons", List.of("Table could not be read: " + e.getMessage()));
+ tableDetailsList.add(tableDetails);
+ continue;
+ }
logger.info("Fetched syncData for schema {} and table {}: {}", obj.getSchemaName(), obj.getTableName(),
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(syncData));
@@ -179,7 +209,7 @@ private String startDataSync(int vanID, String user, String Authorization, Strin
Map syncResult = syncDataToServer(vanID, obj.getSchemaName(),
obj.getTableName(),
- obj.getVanAutoIncColumnName(), obj.getServerColumnName(), syncDataBatch, user,
+ obj.getVanAutoIncColumnName(), columns[1], syncDataBatch, user,
Authorization, token);
if (syncResult == null) {
@@ -221,7 +251,7 @@ private String startDataSync(int vanID, String user, String Authorization, Strin
Map syncResult = syncDataToServer(vanID, obj.getSchemaName(),
obj.getTableName(),
- obj.getVanAutoIncColumnName(), obj.getServerColumnName(), syncDataBatch, user,
+ obj.getVanAutoIncColumnName(), columns[1], syncDataBatch, user,
Authorization, token);
if (syncResult == null) {
@@ -407,10 +437,59 @@ private String startDataSync(int vanID, String user, String Authorization, Strin
private List getVanAndServerColumns(Integer groupID) throws Exception {
List syncUtilityClassList = getVanAndServerColumnList(groupID);
+ if (stopTBLaptop)
+ syncUtilityClassList = skipTablesUploadedByLaterGroup(syncUtilityClassList, groupID,
+ syncutilityClassRepo.findByDeletedOrderBySyncTableDetailID(false));
logger.debug("Fetched SyncUtilityClass list for groupID {}: {}", groupID, syncUtilityClassList);
return syncUtilityClassList;
}
+ /**
+ * Inventory tables are configured twice: group 5 (old lists without the store columns of stock-outs and patient
+ * issues, batch insert fails) and group 9 "Inventory" (complete). Group 5 ran first and marked the rows uploaded,
+ * so central got stock-outs without their camp store. A table that a later group also uploads is skipped here.
+ */
+ static List skipTablesUploadedByLaterGroup(List groupTables, int groupID,
+ List allTables) {
+ Map lastGroup = new HashMap<>();
+ for (SyncUtilityClass t : allTables) {
+ if (t.getTableName() != null && t.getSyncTableGroupID() != null)
+ lastGroup.merge(t.getTableName().toLowerCase(), t.getSyncTableGroupID(), Math::max);
+ }
+ List kept = new ArrayList<>();
+ for (SyncUtilityClass t : groupTables) {
+ Integer last = t.getTableName() == null ? null : lastGroup.get(t.getTableName().toLowerCase());
+ if (last == null || last <= groupID)
+ kept.add(t);
+ }
+ return kept;
+ }
+
+ // columns renamed by DB scripts after the sync table lists were written (tb_diagnostic_order: V114 renamed
+ // reason_for_refusal to reason_to_close, V117 reverted it, V125 renamed it again)
+ private static final Map> RENAMED_COLUMNS = Map.of("tb_diagnostic_order",
+ Map.of("reason_for_refusal", "reason_to_close", "reason_to_close", "reason_for_refusal"));
+
+ /**
+ * The sync table list may still name a column by its old name (laptop m_synctabledetail has reason_for_refusal,
+ * the table has reason_to_close) and the whole upload stopped with "bad SQL grammar". The name the table really
+ * has is used in both the van and the server column lists (central has the same DB version).
+ */
+ static String[] useRenamedColumns(String tableName, String vanColumns, String serverColumns,
+ Set tableColumns) {
+ Map renamed = tableName == null ? null : RENAMED_COLUMNS.get(tableName.toLowerCase());
+ if (renamed == null || tableColumns == null || tableColumns.isEmpty())
+ return new String[] { vanColumns, serverColumns };
+ for (Map.Entry r : renamed.entrySet()) {
+ if (!tableColumns.contains(r.getKey()) && tableColumns.contains(r.getValue())) {
+ String listed = "(?i)\\b" + r.getKey() + "\\b";
+ vanColumns = vanColumns == null ? null : vanColumns.replaceAll(listed, r.getValue());
+ serverColumns = serverColumns == null ? null : serverColumns.replaceAll(listed, r.getValue());
+ }
+ }
+ return new String[] { vanColumns, serverColumns };
+ }
+
public List getVanAndServerColumnList(Integer groupID) throws Exception {
List syncUtilityClassList = syncutilityClassRepo
.findBySyncTableGroupIDAndDeletedOrderBySyncTableDetailID(groupID, false);
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentral.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentral.java
index 0da5bf4b..75aa52a4 100644
--- a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentral.java
+++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentral.java
@@ -173,6 +173,22 @@ public int checkRecordIsAlreadyPresentOrNot(String schemaName, String tableName,
}
}
+ /**
+ * Central ID of a row a van uploaded earlier, found by the van's own ID (VanSerialNo) and the van; null if that
+ * row is not on central (yet).
+ */
+ public Long findCentralIdOfVanRow(String schemaName, String tableName, String idColumn, Object vanSerialNo,
+ String vanID) {
+ if (!isValidSchemaName(schemaName) || !isValidDatabaseIdentifierCharacter(tableName)
+ || !isValidDatabaseIdentifierCharacter(idColumn)) {
+ throw new IllegalArgumentException("Invalid identifiers provided.");
+ }
+ List ids = getJdbcTemplate().queryForList("SELECT " + idColumn + " FROM " + schemaName + "." + tableName
+ + " WHERE VanSerialNo = ? AND VanID = ? ORDER BY " + idColumn + " DESC LIMIT 1", Long.class,
+ String.valueOf(vanSerialNo), vanID);
+ return ids.isEmpty() ? null : ids.get(0);
+ }
+
public int[] syncDataToCentralDB(String schema, String tableName, String serverColumns, String query,
List syncDataList) {
jdbcTemplate = getJdbcTemplate();
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentralDownload.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentralDownload.java
index c76962b4..50c49c0e 100644
--- a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentralDownload.java
+++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentralDownload.java
@@ -205,7 +205,8 @@ else if (masterType.equalsIgnoreCase("P")) {
}
logger.info("Select query central: " + baseQuery);
logger.info("Last Downloaded Date " + lastDownloadDate);
- logger.info("Result set Details: " + resultSetList);
+ // Row count only: logging the rows of a large master (villages, 661k rows) exhausted the heap.
+ logger.info("Rows returned: " + resultSetList.size());
return resultSetList;
}
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentFetchService.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentFetchService.java
new file mode 100644
index 00000000..a03526f9
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentFetchService.java
@@ -0,0 +1,62 @@
+package com.iemr.mmu.service.dataSyncLayerCentral;
+
+import java.time.Duration;
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+
+/***
+ * @purpose Hands back a short-lived presigned URL for the latest successfully-pushed
+ * diagnostic document matching a beneficiary+documentType, generated on demand from
+ * the object's S3 key (s3_path) - the bucket is private, so no permanent URL is
+ * ever persisted or handed out.
+ */
+@Service
+public class DiagnosticDocumentFetchService {
+
+ private static final Duration URL_VALIDITY = Duration.ofMinutes(15);
+
+ @Value("${diagnostic.documents.s3.bucket}")
+ private String bucket;
+
+ @Autowired
+ private DiagnosticDocumentRepository diagnosticDocumentRepository;
+
+ @Autowired
+ private S3Presigner s3Presigner;
+
+ /***
+ * @return null if no successfully-pushed document matches, otherwise the download details
+ * (documentType, orderType, externalOrderId, contentType, lastModDate, downloadUrl,
+ * urlExpiresInSeconds)
+ */
+ public Map getLatestDocumentDownload(Long beneficiaryId, String documentType) {
+ Map row = diagnosticDocumentRepository.findLatestDocument(beneficiaryId, documentType);
+ if (row == null) {
+ return null;
+ }
+
+ String s3Key = (String) row.get("s3_path");
+ GetObjectRequest getObjectRequest = GetObjectRequest.builder().bucket(bucket).key(s3Key).build();
+ GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder().signatureDuration(URL_VALIDITY)
+ .getObjectRequest(getObjectRequest).build();
+ String downloadUrl = s3Presigner.presignGetObject(presignRequest).url().toString();
+
+ Map result = new java.util.HashMap<>();
+ result.put("externalOrderId", row.get("external_order_id"));
+ result.put("orderType", row.get("order_type"));
+ result.put("documentType", row.get("document_type"));
+ result.put("contentType", row.get("content_type"));
+ result.put("originalFileName", row.get("original_file_name"));
+ result.put("lastModDate", String.valueOf(row.get("last_mod_date")));
+ result.put("downloadUrl", downloadUrl);
+ result.put("urlExpiresInSeconds", URL_VALIDITY.getSeconds());
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentIngestService.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentIngestService.java
new file mode 100644
index 00000000..d04d841f
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentIngestService.java
@@ -0,0 +1,139 @@
+package com.iemr.mmu.service.dataSyncLayerCentral;
+
+import java.lang.reflect.Type;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.ServerSideEncryption;
+
+/***
+ * @purpose Receives a batch of decrypted diagnostic documents pushed from a van and stores
+ * each in S3 - purely a storage relay, no database writes here. The pushing van
+ * persists its own record locally (DiagnosticDocumentPushServiceImpl.markPushedToCentral,
+ * keyed off the s3Path this returns in each ack) - the central server's own database is
+ * left untouched.
+ */
+@Service
+public class DiagnosticDocumentIngestService {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
+ private static final Gson GSON = new Gson();
+ private static final String ENCRYPTED_FILE_SUFFIX = ".enc";
+
+ @Value("${diagnostic.documents.s3.bucket}")
+ private String bucket;
+
+ @Autowired
+ private S3Client s3Client;
+
+ public String ingestDocuments(String requestOBJ) throws Exception {
+ Type listType = new TypeToken>>() {
+ }.getType();
+ List> items = GSON.fromJson(requestOBJ, listType);
+ if (items == null || items.isEmpty()) {
+ return GSON.toJson(new ArrayList<>());
+ }
+
+ List> acks = new ArrayList<>();
+ for (Map item : items) {
+ acks.add(ingestOne(item));
+ }
+ return GSON.toJson(acks);
+ }
+
+ private Map ingestOne(Map item) {
+ Map ack = new HashMap<>();
+ ack.put("documentId", item.get("documentId"));
+ ack.put("diagnosticOrderId", item.get("diagnosticOrderId"));
+ ack.put("externalOrderId", item.get("externalOrderId"));
+ ack.put("documentType", item.get("documentType"));
+
+ try {
+ ack.put("documentId", asLong(item.get("documentId")));
+ Long diagnosticOrderId = asLong(item.get("diagnosticOrderId"));
+ ack.put("diagnosticOrderId", diagnosticOrderId);
+ String documentType = (String) item.get("documentType");
+
+ byte[] plaintext = Base64.getDecoder().decode((String) item.get("fileContentBase64"));
+
+ String sha256Hash = (String) item.get("sha256Hash");
+ if (sha256Hash != null && !sha256Hash.equalsIgnoreCase(sha256Hex(plaintext))) {
+ ack.put("status", "FAILED");
+ ack.put("error", "sha256 mismatch on receipt");
+ return ack;
+ }
+
+ Long beneficiaryId = asLong(item.get("beneficiaryId"));
+ Long vanId = asLong(item.get("vanID"));
+ String orderType = (String) item.get("orderType");
+ String storedFileName = (String) item.get("storedFileName");
+ if (vanId == null || beneficiaryId == null || orderType == null || documentType == null
+ || storedFileName == null) {
+ ack.put("status", "FAILED");
+ ack.put("error", "Missing vanID, beneficiaryId, orderType, documentType or storedFileName");
+ return ack;
+ }
+ String s3Key = vanId + "/" + beneficiaryId + "/" + orderType + "/" + documentType + "/"
+ + objectFileName(storedFileName, (String) item.get("fileExtension"));
+ String contentType = (String) item.get("contentType");
+
+ s3Client.putObject(
+ PutObjectRequest.builder().bucket(bucket).key(s3Key)
+ .contentType(contentType != null ? contentType : "application/octet-stream")
+ .serverSideEncryption(ServerSideEncryption.AES256).build(),
+ RequestBody.fromBytes(plaintext));
+
+ ack.put("status", "SUCCESS");
+ ack.put("s3Path", s3Key);
+ } catch (Exception e) {
+ logger.error("Error ingesting diagnostic document: documentId=" + item.get("documentId")
+ + ", diagnosticOrderId=" + item.get("diagnosticOrderId")
+ + ", documentType=" + item.get("documentType"), e);
+ ack.put("status", "FAILED");
+ ack.put("error", e.getMessage());
+ }
+ return ack;
+ }
+
+ /***
+ * @purpose The van stores files encrypted as ".enc", but S3 holds the decrypted
+ * content - so the object is named with the real extension the van derived from
+ * its content type (e.g. CAD.enc -> CAD.pdf), not the misleading ".enc".
+ */
+ private static String objectFileName(String storedFileName, String fileExtension) {
+ String baseName = storedFileName.endsWith(ENCRYPTED_FILE_SUFFIX)
+ ? storedFileName.substring(0, storedFileName.length() - ENCRYPTED_FILE_SUFFIX.length())
+ : storedFileName;
+ return fileExtension != null && !fileExtension.isEmpty() ? baseName + "." + fileExtension : baseName;
+ }
+
+ private static Long asLong(Object value) {
+ return value == null ? null : ((Number) value).longValue();
+ }
+
+ private static String sha256Hex(byte[] data) throws Exception {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(data);
+ StringBuilder hex = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ hex.append(String.format(Locale.ROOT, "%02x", b));
+ }
+ return hex.toString();
+ }
+}
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentPushServiceImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentPushServiceImpl.java
new file mode 100644
index 00000000..cc817200
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentPushServiceImpl.java
@@ -0,0 +1,274 @@
+package com.iemr.mmu.service.dataSyncLayerCentral;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.reflect.TypeToken;
+import com.iemr.mmu.utils.CryptoUtil;
+import com.iemr.mmu.utils.RestTemplateUtil;
+
+import java.lang.reflect.Type;
+
+/***
+ * @purpose Reads MMU-API's own locally-pending diagnostic documents (docsProcessed='N',
+ * shared db_iemr.tb_diagnostic_document table), decrypts the file off the shared
+ * filesystem, and pushes each batch to the further central server's
+ * /dataSync/diagnostic-documents endpoint - the same relay shape as
+ * UploadDataToServerImpl's push to dataSyncUploadUrl, just for this table instead
+ * of the generic sync-group config. MMU-API's own /dataSync/diagnostic-documents
+ * ingest endpoint (DiagnosticDocumentCentralIngestService) is untouched by this -
+ * this service is a separate outbound relay, not a caller of it.
+ */
+@Service
+public class DiagnosticDocumentPushServiceImpl {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
+
+ private static final Map CONTENT_TYPE_EXTENSIONS = new HashMap<>();
+ static {
+ CONTENT_TYPE_EXTENSIONS.put("application/pdf", "pdf");
+ CONTENT_TYPE_EXTENSIONS.put("image/jpeg", "jpg");
+ CONTENT_TYPE_EXTENSIONS.put("image/png", "png");
+ }
+
+ @Value("${diagnostic.documents.storage-root}")
+ private String storageRoot;
+
+ @Value("${diagnosticDocumentUploadUrl}")
+ private String diagnosticDocumentUploadUrl;
+
+ @Value("${diagnosticDocument.push.batchSize:3}")
+ private int batchSize;
+
+ @Autowired
+ private DiagnosticDocumentRepository diagnosticDocumentRepository;
+
+ @Autowired
+ private CryptoUtil cryptoUtil;
+
+ public String pushPendingDocuments(String Authorization) throws Exception {
+ List> pendingRows = diagnosticDocumentRepository.findPendingDocuments();
+ int totalSucceeded = 0;
+ List failureReasons = new ArrayList<>();
+
+ for (int offset = 0; offset < pendingRows.size(); offset += batchSize) {
+ List> rows = pendingRows.subList(offset, Math.min(offset + batchSize, pendingRows.size()));
+
+ List> payloadItems = new ArrayList<>();
+ Map> rowsById = new HashMap<>();
+ for (Map row : rows) {
+ Long rowId = asLong(row.get("id"));
+ String base64Plaintext;
+ try {
+ String storedPath = (String) row.get("stored_path");
+ Path filePath = Paths.get(storageRoot, storedPath);
+ String encryptedPayload = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8);
+ base64Plaintext = cryptoUtil.decrypt(encryptedPayload);
+ } catch (Exception e) {
+ logger.warn("Skipping diagnostic document push, could not read file off disk: id={}, error={}",
+ rowId, e.getMessage());
+ markFailed(rowId, "Could not read file off shared filesystem: " + e.getMessage(), failureReasons);
+ continue;
+ }
+ if (base64Plaintext == null) {
+ logger.warn("Skipping diagnostic document push, decrypt failed: id={}", rowId);
+ markFailed(rowId, "Decrypt failed", failureReasons);
+ continue;
+ }
+
+ Long diagnosticOrderId = asLong(row.get("diagnostic_order_id"));
+ String externalOrderId = (String) row.get("external_order_id");
+ String documentType = (String) row.get("document_type");
+ String contentType = (String) row.get("content_type");
+
+ Map item = new HashMap<>();
+ item.put("documentId", rowId);
+ item.put("diagnosticOrderId", diagnosticOrderId);
+ item.put("externalOrderId", externalOrderId);
+ item.put("beneficiaryId", row.get("beneficiary_id"));
+ item.put("orderType", row.get("order_type"));
+ item.put("documentType", documentType);
+ item.put("storedFileName", row.get("stored_file_name"));
+ item.put("sha256Hash", row.get("sha256_hash"));
+ item.put("contentType", contentType);
+ item.put("fileExtension", extensionFor(contentType));
+ item.put("originalFileName", row.get("original_file_name"));
+ item.put("vanID", row.get("vanID"));
+ item.put("parkingPlaceID", row.get("parkingPlaceID"));
+ item.put("vanSerialNo", row.get("vanSerialNo"));
+ item.put("fileContentBase64", base64Plaintext);
+ payloadItems.add(item);
+ rowsById.put(rowId, row);
+ }
+
+ if (payloadItems.isEmpty()) {
+ continue;
+ }
+
+ String requestOBJ = new Gson().toJson(payloadItems);
+ List> acks;
+ try {
+ HttpEntity request = RestTemplateUtil.createRequestEntity(requestOBJ, Authorization, "datasync");
+ RestTemplate restTemplate = new RestTemplate();
+ ResponseEntity response = restTemplate.exchange(diagnosticDocumentUploadUrl, HttpMethod.POST,
+ request, String.class);
+
+ if (response == null || !response.hasBody()) {
+ logger.warn("No response body from central server for diagnostic document push, marking batch failed");
+ markBatchFailed(rowsById, "No response body from central server", failureReasons);
+ continue;
+ }
+
+ // Central server wraps every response in the shared OutputResponse envelope
+ // ({"data": [...], "statusCode":200, ...}) - the ack array lives under "data".
+ JsonElement parsedBody = JsonParser.parseString(response.getBody());
+ if (!parsedBody.isJsonObject()) {
+ logger.warn("Unexpected response shape from central server for diagnostic document push, marking batch failed");
+ markBatchFailed(rowsById, "Unexpected response shape from central server", failureReasons);
+ continue;
+ }
+ JsonObject envelope = parsedBody.getAsJsonObject();
+ if (!envelope.has("statusCode") || envelope.get("statusCode").getAsInt() != 200
+ || !envelope.has("data")) {
+ logger.warn("Central server reported failure for diagnostic document push batch, marking batch failed: {}",
+ response.getBody());
+ markBatchFailed(rowsById, "Central server reported failure: " + response.getBody(), failureReasons);
+ continue;
+ }
+
+ Type ackListType = new TypeToken>>() {
+ }.getType();
+ acks = new Gson().fromJson(envelope.get("data"), ackListType);
+ if (acks == null) {
+ markBatchFailed(rowsById, "Central server returned no acknowledgements", failureReasons);
+ continue;
+ }
+ } catch (Exception e) {
+ logger.error("Error calling central server for diagnostic document push, marking batch failed", e);
+ markBatchFailed(rowsById, "Error calling central server: " + e.getMessage(), failureReasons);
+ continue;
+ }
+
+ int batchSuccessCount = 0;
+ Map> unmatchedRowsById = new HashMap<>(rowsById);
+ for (Map ack : acks) {
+ Long rowId = ack.get("documentId") instanceof Number ? asLong(ack.get("documentId")) : null;
+ if (rowId == null || unmatchedRowsById.remove(rowId) == null) {
+ continue;
+ }
+ if ("SUCCESS".equalsIgnoreCase((String) ack.get("status"))) {
+ diagnosticDocumentRepository.markPushedToCentral(rowId, (String) ack.get("s3Path"));
+ batchSuccessCount++;
+ } else {
+ String reason = (String) ack.get("error");
+ logger.warn("Central server rejected diagnostic document push: id={}, externalOrderId={}, documentType={}, error={}",
+ rowId, ack.get("externalOrderId"), ack.get("documentType"), reason);
+ markFailed(rowId, reason != null ? reason : "Central server rejected the document", failureReasons);
+ }
+ }
+ if (!unmatchedRowsById.isEmpty()) {
+ // Central sent back fewer acks than documents we sent - whatever wasn't
+ // accounted for must not be silently left at its previous status forever.
+ logger.warn(
+ "Diagnostic document push: {} row(s) in this batch got no matching ack back, marking failed",
+ unmatchedRowsById.size());
+ markBatchFailed(unmatchedRowsById, "No acknowledgement received from central server", failureReasons);
+ }
+
+ totalSucceeded += batchSuccessCount;
+ logger.info("Diagnostic document push batch complete: attempted={}, succeeded={}", payloadItems.size(),
+ batchSuccessCount);
+ }
+
+ if (pendingRows.isEmpty()) {
+ return "No data to sync";
+ }
+
+ // Same table-level summary shape as /van-to-server (UploadDataToServerImpl) so the
+ // data sync screen can read both the same way.
+ int totalRecords = pendingRows.size();
+ int failedRecords = failureReasons.size();
+ String status;
+ if (failedRecords == 0) {
+ status = "success";
+ } else if (totalSucceeded == 0) {
+ status = "failed";
+ } else {
+ status = "partial";
+ }
+
+ Map finalResponse = new LinkedHashMap<>();
+ finalResponse.put("response",
+ "success".equals(status) ? "Data sync completed successfully" : "Data sync completed with failures");
+ finalResponse.put("schemaName", "db_iemr");
+ finalResponse.put("tableName", "tb_diagnostic_document");
+ finalResponse.put("status", status);
+ finalResponse.put("totalRecords", totalRecords);
+ finalResponse.put("successfulRecords", totalSucceeded);
+ finalResponse.put("failedRecords", failedRecords);
+ if (!failureReasons.isEmpty()) {
+ finalResponse.put("failureReasons", failureReasons);
+ }
+
+ logger.info("Diagnostic document push complete: {} (Success: {}, Failed: {}, Total: {})", status,
+ totalSucceeded, failedRecords, totalRecords);
+ return new Gson().toJson(finalResponse);
+ }
+
+ /***
+ * @purpose Marks one row failed locally and records its reason for the run summary.
+ */
+ private void markFailed(Long rowId, String reason, List failureReasons) {
+ diagnosticDocumentRepository.markPushFailed(rowId, reason);
+ failureReasons.add("documentId " + rowId + ": " + reason);
+ }
+
+ /***
+ * @purpose Called when the whole batch call to the central server fails (no/garbled
+ * response, non-200, or a thrown exception) - marks every row that was in this
+ * batch as failed, matching how /van-to-server marks a whole batch failed on a
+ * connection error, rather than leaving them stuck at docsProcessed='N' forever.
+ */
+ private void markBatchFailed(Map> rowsById, String reason, List failureReasons) {
+ for (Long rowId : rowsById.keySet()) {
+ markFailed(rowId, reason, failureReasons);
+ }
+ }
+
+ private static Long asLong(Object value) {
+ return value == null ? null : ((Number) value).longValue();
+ }
+
+ private static String extensionFor(String contentType) {
+ if (contentType == null) {
+ return "bin";
+ }
+ String extension = CONTENT_TYPE_EXTENSIONS.get(contentType.toLowerCase());
+ if (extension != null) {
+ return extension;
+ }
+ int slashIndex = contentType.indexOf('/');
+ return slashIndex >= 0 && slashIndex < contentType.length() - 1 ? contentType.substring(slashIndex + 1) : "bin";
+ }
+}
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentRepository.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentRepository.java
new file mode 100644
index 00000000..53c30c5f
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentRepository.java
@@ -0,0 +1,95 @@
+package com.iemr.mmu.service.dataSyncLayerCentral;
+
+import java.util.List;
+import java.util.Map;
+
+import javax.sql.DataSource;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+/***
+ * @purpose Local (van) side persistence for the diagnostic-document push pipeline - the central
+ * server is a pure S3 storage relay (DiagnosticDocumentCentralIngestService) and writes
+ * nothing to its own database, so this repository only ever operates on the pushing
+ * van's own local db_iemr.tb_diagnostic_document rows.
+ */
+@Service
+public class DiagnosticDocumentRepository {
+
+ @Autowired
+ private DataSource dataSource;
+
+ private JdbcTemplate jdbcTemplate;
+
+ private JdbcTemplate getJdbcTemplate() {
+ if (this.jdbcTemplate == null) {
+ this.jdbcTemplate = new JdbcTemplate(dataSource);
+ }
+ return this.jdbcTemplate;
+ }
+
+ /***
+ * @purpose Latest successfully-pushed document for a beneficiary+documentType, used to hand
+ * back a fresh presigned URL on demand instead of persisting a permanent (and, for a
+ * private bucket, non-functional) URL. s3_path holds the S3 key for a
+ * docsProcessed='P' row (markPushedToCentral() below writes it there).
+ */
+ public Map findLatestDocument(Long beneficiaryId, String documentType) {
+ String query = "SELECT id, external_order_id, order_type, document_type, s3_path, content_type, "
+ + "original_file_name, last_mod_date FROM db_iemr.tb_diagnostic_document "
+ + "WHERE beneficiary_id = ? AND document_type = ? AND docsProcessed = 'P' "
+ + "ORDER BY last_mod_date DESC, id DESC LIMIT 1";
+ List> rows = getJdbcTemplate().queryForList(query, beneficiaryId, documentType);
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+
+ /***
+ * @purpose Snapshot of every locally-pending row's metadata (docsProcessed='N' or 'F') at
+ * the start of a push run, taken in one query so a row this same run marks 'F'
+ * partway through (e.g. an auth failure) is never re-picked-up later in the same
+ * run - it only becomes eligible again on the next trigger, alongside whatever's
+ * newly 'N' by then. Only metadata is loaded here (id, hashes, filenames, etc.) -
+ * each row's actual file content is still read off disk and decrypted one batch at
+ * a time by the caller, so the whole backlog's decrypted content is never held in
+ * memory at once.
+ */
+ public List> findPendingDocuments() {
+ String query = "SELECT id, diagnostic_order_id, external_order_id, beneficiary_id, order_type, document_type, "
+ + "stored_file_name, stored_path, sha256_hash, content_type, original_file_name, "
+ + "vanID, parkingPlaceID, vanSerialNo FROM db_iemr.tb_diagnostic_document "
+ + "WHERE docsProcessed = 'N' OR docsProcessed = 'F' ORDER BY id ASC";
+ return getJdbcTemplate().queryForList(query);
+ }
+
+ /***
+ * @purpose Marks a document as successfully pushed on the LOCAL (van) row - the central
+ * server itself writes nothing to its own database. s3Path is the S3 key the
+ * central server's ack reported back, persisted here so the van's own local DB
+ * knows where the document ended up, not just that it did.
+ */
+ public void markPushedToCentral(Long id, String s3Path) {
+ String update = "UPDATE db_iemr.tb_diagnostic_document SET processed = 'N', docsProcessed = 'P', "
+ + "s3_path = ?, docSyncedDate = NOW(), docSyncFailureReason = NULL, last_mod_date = NOW() WHERE id = ?";
+ getJdbcTemplate().update(update, s3Path, id);
+ }
+
+ /***
+ * @purpose Marks a document push to the further central server as failed - docsProcessed='F',
+ * matching the P/F status convention used by the generic sync pipeline's
+ * DataSyncRepository.updateProcessedFlagInVan for /van-to-server. Persists why in
+ * docSyncFailureReason (mirroring the generic pipeline's own SyncFailureReason, kept
+ * separate since this pipeline's docsProcessed/docSyncedDate are their own dedicated
+ * columns) so a failure isn't only visible in the application log. Also clears
+ * s3_path - otherwise a row that succeeded once, then got re-attempted and failed,
+ * would keep showing a stale S3 key while docsProcessed says 'F'. docSyncedDate is
+ * left untouched - it records the last time this row was actually confirmed synced,
+ * if ever.
+ */
+ public void markPushFailed(Long id, String reason) {
+ String update = "UPDATE db_iemr.tb_diagnostic_document SET processed = 'N', docsProcessed = 'F', "
+ + "s3_path = NULL, docSyncFailureReason = ?, last_mod_date = NOW() WHERE id = ?";
+ getJdbcTemplate().update(update, reason, id);
+ }
+}
diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBImpl.java
index c0e508cd..4db30fff 100644
--- a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBImpl.java
+++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBImpl.java
@@ -25,6 +25,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -53,6 +54,21 @@ public class GetDataFromVanAndSyncToDBImpl implements GetDataFromVanAndSyncToDB
@Value("${downSync.markDeliveredOnUpSync:true}")
private boolean markDownSyncedOnUpSync;
+ // fill VanSerialNo / SyncFacilityID from the van row when not sent by name (see fillVanKeys)
+ @Value("${dataSync.fillVanKeys:true}")
+ private boolean fillVanKeysEnabled = true;
+
+ // replace van IDs in link columns by central IDs (see translateVanLinks); off by default, on for StopTB central
+ @Value("${dataSync.translateVanLinks:false}")
+ private boolean translateVanLinksEnabled;
+
+ // link column -> {table it points to, that table's ID column}
+ private static final Map VAN_LINKS = new LinkedHashMap<>();
+ static {
+ VAN_LINKS.put("BenVisitID", new String[] { "t_benvisitdetail", "BenVisitID" });
+ VAN_LINKS.put("PrescriptionID", new String[] { "t_prescription", "PrescriptionID" });
+ }
+
private static final Map> TABLE_GROUPS = new HashMap<>();
static {
TABLE_GROUPS.put(1,
@@ -372,7 +388,21 @@ private boolean performGenericTableSync(SyncUploadDataDigester syncUploadDataDig
// CRITICAL FIX: Keep null as null, don't convert to string
cleanRecord.put(cleanKey.trim(), value);
}
-
+ if (fillVanKeysEnabled)
+ fillVanKeys(cleanRecord, serverColumnsList, vanAutoIncColumnName);
+ if (translateVanLinksEnabled) {
+ Object linkVanID = cleanRecord.get("VanID") != null ? cleanRecord.get("VanID") : cleanRecord.get("vanID");
+ String linkError = translateVanLinks(schemaName, syncTableName, cleanRecord, serverColumnsList,
+ linkVanID == null ? null : String.valueOf(linkVanID));
+ if (linkError != null) {
+ syncResults.add(new SyncResult(schemaName, syncTableName,
+ String.valueOf(cleanRecord.get(vanAutoIncColumnName)), syncUploadDataDigester.getSyncedBy(),
+ false, linkError));
+ overallSuccess = false;
+ continue;
+ }
+ }
+
String vanSerialNo = String.valueOf(cleanRecord.get(vanAutoIncColumnName));
// Column may be "vanID" (Stop TB tables) or "VanID" (standard tables) — check both
@@ -606,6 +636,57 @@ private boolean performGenericTableSync(SyncUploadDataDigester syncUploadDataDig
return overallSuccess;
}
+ /**
+ * m_synctabledetail maps van columns to server columns by position: the van's own ID (VanAutoIncColumnName,
+ * e.g. BenVisitID, ItemStockEntryID) sits where the server list has VanSerialNo. Records are filled by name, so
+ * without this VanSerialNo stays NULL: the row cannot be matched on the next upload (duplicates) and joins on
+ * VanSerialNo break. Same for SyncFacilityID, which some van lists do not send: it is the row's store.
+ */
+ static void fillVanKeys(Map record, List serverColumns, String vanAutoIncColumnName) {
+ if (hasColumn(serverColumns, "VanSerialNo") && record.get("VanSerialNo") == null
+ && vanAutoIncColumnName != null && record.get(vanAutoIncColumnName) != null) {
+ record.put("VanSerialNo", record.get(vanAutoIncColumnName));
+ }
+ if (hasColumn(serverColumns, "SyncFacilityID") && record.get("SyncFacilityID") == null
+ && record.get("FacilityID") != null) {
+ record.put("SyncFacilityID", record.get("FacilityID"));
+ }
+ }
+
+ /**
+ * A van sends its own IDs in link columns (BenVisitID, PrescriptionID). On central those numbers belong to other
+ * rows (other vans, central data): the link pointed to the wrong visit/prescription, or the foreign key rejected
+ * the row. The link is replaced by the central ID of the row the van uploaded with that VanSerialNo. If that row
+ * is not on central yet, the record is not saved (returned reason) and is sent again on the next upload.
+ *
+ * @return null when done, or the reason the record cannot be saved yet
+ */
+ String translateVanLinks(String schemaName, String tableName, Map record,
+ List serverColumns, String vanID) {
+ for (Map.Entry link : VAN_LINKS.entrySet()) {
+ String column = link.getKey();
+ String parentTable = link.getValue()[0];
+ Object vanValue = record.get(column);
+ if (vanValue == null || vanID == null || tableName.equalsIgnoreCase(parentTable)
+ || !hasColumn(serverColumns, column))
+ continue;
+ Long centralID = dataSyncRepositoryCentral.findCentralIdOfVanRow(schemaName, parentTable,
+ link.getValue()[1], vanValue, vanID);
+ if (centralID == null)
+ return "Linked " + parentTable + " (van ID " + vanValue + ") not on central yet; sent again on next upload";
+ record.put(column, centralID);
+ }
+ return null;
+ }
+
+ private static boolean hasColumn(List columns, String name) {
+ for (String column : columns) {
+ if (column.trim().equals(name))
+ return true;
+ }
+ return false;
+ }
+
private void flagUpSyncedRecordsForDownSync(String schemaName, String tableName, List syncResults,
Map vanIDByResultIndex) {
if (!markDownSyncedOnUpSync || vanIDByResultIndex.isEmpty())
diff --git a/src/main/java/com/iemr/mmu/utils/CryptoUtil.java b/src/main/java/com/iemr/mmu/utils/CryptoUtil.java
new file mode 100644
index 00000000..c964fdf2
--- /dev/null
+++ b/src/main/java/com/iemr/mmu/utils/CryptoUtil.java
@@ -0,0 +1,42 @@
+package com.iemr.mmu.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/**
+ * Decrypts diagnostic-document files written by FLW-API's own CryptoUtil
+ * (same AES/ECB/PKCS5Padding scheme and key) so they can be read directly
+ * off the shared filesystem before pushing to the further central server.
+ */
+@Service
+public class CryptoUtil {
+
+ private static final Logger logger = LoggerFactory.getLogger(CryptoUtil.class);
+ private static final String ALGORITHM = "AES";
+ private static final String SECRET_KEY = "dev-envro-secret";
+
+ public String decrypt(String encryptedValue) {
+ try {
+ SecretKey secretKey = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
+ Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+ cipher.init(Cipher.DECRYPT_MODE, secretKey);
+ byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedValue));
+ return removePadding(new String(decryptedBytes, StandardCharsets.UTF_8));
+ } catch (Exception e) {
+ logger.error("Exception while decrypting diagnostic document", e);
+ return null;
+ }
+ }
+
+ private String removePadding(String value) {
+ int paddingLength = value.charAt(value.length() - 1);
+ return value.substring(0, value.length() - paddingLength);
+ }
+}
diff --git a/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java b/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java
new file mode 100644
index 00000000..c10e6230
--- /dev/null
+++ b/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java
@@ -0,0 +1,272 @@
+/*
+* 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.benid;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.hamcrest.Matchers;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.client.ExpectedCount;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+class BenIdAutoTopUpServiceTest {
+
+ private static final String GENERATE = "http://central/common-api/beneficiary/generateBeneficiaryIDs";
+ private static final String IMPORT = "http://local/id/saveGeneratedBenIDToLocalServer";
+ private static final String COUNT = "http://local/id/checkAvailablBenIDLocalServer";
+
+ @TempDir
+ Path dataDir;
+
+ private BenIdAutoTopUpService service;
+ private CentralAuthService centralAuth;
+ private MockRestServiceServer server;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ centralAuth = mock(CentralAuthService.class);
+ when(centralAuth.isEnabled()).thenReturn(true);
+ when(centralAuth.getVanID()).thenReturn(403);
+ when(centralAuth.getState()).thenReturn(CentralAuthService.State.CONNECTED);
+ when(centralAuth.getCentralSessionKey()).thenReturn("SESSION-KEY");
+
+ service = new BenIdAutoTopUpService();
+ ReflectionTestUtils.setField(service, "centralAuthService", centralAuth);
+ ReflectionTestUtils.setField(service, "benGenUrlCentral", GENERATE);
+ ReflectionTestUtils.setField(service, "benImportUrlLocal", IMPORT);
+ ReflectionTestUtils.setField(service, "benCheckUrlLocal", COUNT);
+ ReflectionTestUtils.setField(service, "lowerLimit", 2000L);
+ ReflectionTestUtils.setField(service, "batchSize", 1000L);
+ ReflectionTestUtils.setField(service, "bootstrapSize", 10000L);
+ ReflectionTestUtils.setField(service, "maxPerRequest", 1000L);
+ ReflectionTestUtils.setField(service, "dataDir", dataDir.toString());
+ ReflectionTestUtils.setField(service, "retryDelaysMs", new long[] { 0L });
+ ReflectionTestUtils.setField(service, "connectTimeoutMs", 1000);
+ ReflectionTestUtils.setField(service, "readTimeoutMs", 1000);
+ service.init();
+ server = MockRestServiceServer.bindTo((RestTemplate) ReflectionTestUtils.getField(service, "restTemplate"))
+ .build();
+ }
+
+ @AfterEach
+ void releaseLock() {
+ while (BenIdAutoTopUpService.GENERATION_LOCK.isHeldByCurrentThread()) {
+ BenIdAutoTopUpService.GENERATION_LOCK.unlock();
+ }
+ }
+
+ private void expectCount(long n) {
+ server.expect(requestTo(COUNT))
+ .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"response\":\"" + n + "\"}}",
+ MediaType.APPLICATION_JSON));
+ }
+
+ private static String ids(int n) {
+ StringBuilder sb = new StringBuilder("[");
+ for (int i = 1; i <= n; i++) {
+ sb.append(i > 1 ? "," : "").append("{\"benRegId\":").append(5000 + i)
+ .append(",\"beneficiaryId\":").append(900000000000L + i).append("}");
+ }
+ return sb.append("]").toString();
+ }
+
+ private void expectGenerate(long requested, int returned) {
+ server.expect(requestTo(GENERATE)).andExpect(header("Authorization", "SESSION-KEY"))
+ .andExpect(content().string(Matchers.containsString("\"benIDRequired\":" + requested)))
+ .andExpect(content().string(Matchers.containsString("\"vanID\":403")))
+ .andRespond(withSuccess("{\"statusCode\":200,\"data\":" + ids(returned) + "}",
+ MediaType.APPLICATION_JSON));
+ }
+
+ private void expectImportOk() {
+ server.expect(requestTo(IMPORT)).andExpect(content().string(Matchers.containsString("\"vanID\":403")))
+ .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"response\":\"ok\"}}",
+ MediaType.APPLICATION_JSON));
+ }
+
+ private void run() {
+ ReflectionTestUtils.invokeMethod(service, "runOnce", true);
+ }
+
+ private Path pending() {
+ return dataDir.resolve("benid-pending-import.json");
+ }
+
+ @Test
+ void doesNothingWhenDisabled() throws Exception {
+ when(centralAuth.isEnabled()).thenReturn(false);
+ server.expect(ExpectedCount.never(), requestTo(COUNT));
+ run();
+ server.verify();
+ verify(centralAuth, never()).getCentralSessionKey();
+ }
+
+ @Test
+ void emptyPoolGetsBootstrapSize() {
+ ReflectionTestUtils.setField(service, "bootstrapSize", 800L);
+ expectCount(0);
+ expectGenerate(800, 3);
+ expectImportOk();
+ expectCount(3);
+ run();
+ server.verify();
+ assertFalse(Files.exists(pending()), "pending file removed after a successful import");
+ }
+
+ @Test
+ void lowPoolGetsBatchSize() {
+ expectCount(1999);
+ expectGenerate(1000, 2);
+ expectImportOk();
+ expectCount(2001);
+ run();
+ server.verify();
+ }
+
+ @Test
+ void healthyPoolDoesNotCallCentral() throws Exception {
+ expectCount(2000);
+ server.expect(ExpectedCount.never(), requestTo(GENERATE));
+ run();
+ server.verify();
+ verify(centralAuth, never()).getCentralSessionKey();
+ }
+
+ @Test
+ void noCentralTokenWaitsForLogin() throws Exception {
+ when(centralAuth.getCentralSessionKey()).thenReturn(null);
+ expectCount(0);
+ server.expect(ExpectedCount.never(), requestTo(GENERATE));
+ run();
+ server.verify();
+ assertTrue(((String) ReflectionTestUtils.getField(service, "lastError")).contains("waiting for a user login"));
+ }
+
+ @Test
+ void centralErrorLeavesNothingBehind() {
+ expectCount(0);
+ server.expect(requestTo(GENERATE)).andRespond(withStatus(HttpStatus.GATEWAY_TIMEOUT));
+ run();
+ server.verify();
+ assertFalse(Files.exists(pending()));
+ assertTrue(ReflectionTestUtils.getField(service, "lastError") != null);
+ }
+
+ @Test
+ void failedLocalImportKeepsIdsAndImportsThemNextRun() throws Exception {
+ ReflectionTestUtils.setField(service, "bootstrapSize", 800L);
+ expectCount(0);
+ expectGenerate(800, 3);
+ server.expect(requestTo(IMPORT)).andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR));
+ // next run: pending IDs are imported first, then the pool is healthy
+ expectImportOk();
+ expectCount(2500);
+ run();
+ assertTrue(Files.exists(pending()), "IDs received from central must be kept");
+ assertEquals(3, Files.readString(pending()).split("benRegId").length - 1);
+
+ run();
+ server.verify();
+ assertFalse(Files.exists(pending()));
+ }
+
+ @Test
+ void skipsWhileManualGenerationRuns() {
+ BenIdAutoTopUpService.GENERATION_LOCK.lock();
+ try {
+ Thread other = new Thread(this::run);
+ server.expect(ExpectedCount.never(), requestTo(COUNT));
+ other.start();
+ other.join(5000);
+ server.verify();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ BenIdAutoTopUpService.GENERATION_LOCK.unlock();
+ }
+ }
+
+ @Test
+ void bigDownloadIsSplitIntoRequestsOfAtMost1000() {
+ ReflectionTestUtils.setField(service, "bootstrapSize", 2500L);
+ expectCount(0);
+ expectGenerate(1000, 2);
+ expectImportOk();
+ expectGenerate(1000, 2);
+ expectImportOk();
+ expectGenerate(500, 2);
+ expectImportOk();
+ expectCount(2500);
+ run();
+ server.verify();
+ }
+
+ @Test
+ void pendingFileThatKeepsFailingIsSetAsideAndTopUpsContinue() throws Exception {
+ Files.writeString(pending(), "[{\"benRegId\":12"); // half-written by a power cut
+ for (int i = 0; i < 3; i++) {
+ server.expect(requestTo(IMPORT)).andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR));
+ }
+ expectCount(2500);
+ run();
+ run();
+ assertTrue(Files.exists(pending()), "kept while retries are left");
+ run();
+ server.verify();
+ assertFalse(Files.exists(pending()), "set aside after 3 failures");
+ try (java.util.stream.Stream files = Files.list(dataDir)) {
+ assertTrue(files.anyMatch(f -> f.getFileName().toString().startsWith("benid-pending-import.failed-")));
+ }
+ }
+
+ @Test
+ void retryWaitIsSpreadPlusMinus20Percent() {
+ ReflectionTestUtils.setField(service, "retryDelaysMs", new long[] { 10000L });
+ for (int i = 0; i < 20; i++) {
+ long before = System.currentTimeMillis();
+ ReflectionTestUtils.invokeMethod(service, "onFailure", new RuntimeException("down"));
+ long wait = (long) ReflectionTestUtils.getField(service, "nextAttemptAt") - before;
+ assertTrue(wait >= 7990 && wait <= 12010, "wait " + wait + " ms is outside 8-12 s");
+ }
+ }
+}
diff --git a/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java b/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java
new file mode 100644
index 00000000..d6ba45a0
--- /dev/null
+++ b/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java
@@ -0,0 +1,276 @@
+/*
+* 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.benid;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.client.ExpectedCount;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+class CentralAuthServiceTest {
+
+ private static final String LOGIN = "http://central/common-api/user/userAuthenticate";
+ private static final String REFRESH = "http://central/common-api/user/refreshToken";
+ private static final String SESSION = "http://central/common-api/user/getLoginResponse";
+
+ @TempDir
+ Path tokenDir;
+
+ private CentralAuthService service;
+ private MockRestServiceServer central;
+
+ @BeforeEach
+ void setUp() {
+ service = newService(true, 403);
+ }
+
+ private CentralAuthService newService(boolean enforce, int vanID) {
+ CentralAuthService s = new CentralAuthService();
+ ReflectionTestUtils.setField(s, "enforceVanID", enforce);
+ ReflectionTestUtils.setField(s, "vanID", vanID);
+ ReflectionTestUtils.setField(s, "centralLoginUrl", LOGIN);
+ ReflectionTestUtils.setField(s, "centralRefreshTokenUrl", REFRESH);
+ ReflectionTestUtils.setField(s, "centralSessionUrl", SESSION);
+ ReflectionTestUtils.setField(s, "tokenDir", tokenDir.toString());
+ ReflectionTestUtils.setField(s, "tokenSecret", "test-secret");
+ ReflectionTestUtils.setField(s, "connectTimeoutMs", 1000);
+ ReflectionTestUtils.setField(s, "readTimeoutMs", 1000);
+ s.init();
+ RestTemplate rt = (RestTemplate) ReflectionTestUtils.getField(s, "restTemplate");
+ central = MockRestServiceServer.bindTo(rt).build();
+ return s;
+ }
+
+ private void expectLoginOk(String refreshToken) {
+ central.expect(requestTo(LOGIN)).andExpect(method(HttpMethod.POST))
+ .andExpect(header("User-Agent", "okhttp/4.12.0"))
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("\"doLogout\":false")))
+ .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"refreshToken\":\"" + refreshToken
+ + "\",\"key\":\"k\"}}", MediaType.APPLICATION_JSON));
+ }
+
+ private void expectLoginRejected() {
+ central.expect(requestTo(LOGIN)).andRespond(withSuccess(
+ "{\"statusCode\":5002,\"errorMessage\":\"Invalid username or password\"}", MediaType.APPLICATION_JSON));
+ }
+
+ @Test
+ void disabledWithoutVanId() {
+ CentralAuthService off = newService(true, 0);
+ assertFalse(off.isEnabled());
+ assertEquals(CentralAuthService.State.DISABLED, off.getState());
+ assertFalse(off.capture("stoptb1", "pw", true));
+ central.verify(); // nothing sent to central
+ }
+
+ @Test
+ void disabledWhenEnforceFlagOff() {
+ CentralAuthService off = newService(false, 403);
+ assertFalse(off.isEnabled());
+ assertFalse(off.capture("stoptb1", "pw", true));
+ central.verify();
+ }
+
+ @Test
+ void captureStoresOnlyEncryptedTokenWithOwnerOnlyPermissions() throws Exception {
+ expectLoginOk("RT-secret-123");
+
+ assertTrue(service.capture("stoptb1", "encrypted-pw", false));
+
+ Path file = tokenDir.resolve("central-refresh-token");
+ String stored = Files.readString(file, StandardCharsets.UTF_8);
+ assertFalse(stored.contains("RT-secret-123"), "token must not be stored in plain text");
+ assertFalse(stored.contains("encrypted-pw"), "password must never be stored");
+ assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file)));
+ assertEquals(CentralAuthService.State.CONNECTED, service.getState());
+ central.verify();
+ }
+
+ @Test
+ void tokenIsReadBackAfterRestart() {
+ expectLoginOk("RT-1");
+ service.capture("stoptb1", "pw", false);
+
+ CentralAuthService restarted = newService(true, 403);
+ assertTrue(restarted.hasToken());
+ assertEquals(CentralAuthService.State.CONNECTED, restarted.getState());
+ }
+
+ @Test
+ void corruptTokenFileMeansNewLoginNeeded() throws Exception {
+ Files.writeString(tokenDir.resolve("central-refresh-token"), "not-a-valid-token");
+ CentralAuthService s = newService(true, 403);
+ assertFalse(s.hasToken());
+ assertEquals(CentralAuthService.State.NOT_CONNECTED, s.getState());
+ }
+
+ @Test
+ void secondLoginIsSkippedWhileTokenWorks() {
+ expectLoginOk("RT-1");
+ service.capture("stoptb1", "pw", false);
+
+ assertFalse(service.capture("stoptb1", "pw", false));
+ central.verify(); // only one central login
+ }
+
+ @Test
+ void rejectedPasswordIsNeverSentAgainWithoutReconnect() {
+ expectLoginRejected();
+
+ assertFalse(service.capture("stoptb1", "wrong", false));
+ assertEquals(CentralAuthService.State.PASSWORD_MISMATCH, service.getState());
+ // next laptop login with the same user must not reach central (account lock risk)
+ assertFalse(service.capture("stoptb1", "wrong", false));
+ central.verify();
+ }
+
+ @Test
+ void dataSyncReconnectRetriesAfterRejection() {
+ expectLoginRejected();
+ expectLoginOk("RT-2");
+
+ service.capture("stoptb1", "wrong", false);
+ assertTrue(service.capture("stoptb1", "central-pw", true));
+ assertEquals(CentralAuthService.State.CONNECTED, service.getState());
+ central.verify();
+ }
+
+ @Test
+ void centralDownIsNotTreatedAsWrongPassword() {
+ central.expect(requestTo(LOGIN)).andRespond(withStatus(HttpStatus.BAD_GATEWAY));
+ expectLoginOk("RT-3");
+
+ assertFalse(service.capture("stoptb1", "pw", false));
+ // network problem: the next login tries again
+ assertTrue(service.capture("stoptb1", "pw", false));
+ central.verify();
+ }
+
+ @Test
+ void sessionKeyFromRefreshTokenAndTokenRotation() throws Exception {
+ expectLoginOk("RT-old");
+ central.expect(requestTo(REFRESH))
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("RT-old")))
+ .andRespond(withSuccess("{\"jwtToken\":\"JWT-1\",\"refreshToken\":\"RT-new\"}",
+ MediaType.APPLICATION_JSON));
+ central.expect(requestTo(SESSION)).andExpect(header("Jwttoken", "JWT-1"))
+ .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"key\":\"SESSION-KEY\"}}",
+ MediaType.APPLICATION_JSON));
+ // the next refresh must use the rotated token
+ central.expect(requestTo(REFRESH))
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("RT-new")))
+ .andRespond(withSuccess("{\"jwtToken\":\"JWT-2\"}", MediaType.APPLICATION_JSON));
+ central.expect(requestTo(SESSION)).andRespond(withSuccess(
+ "{\"statusCode\":200,\"data\":\"{\\\"key\\\":\\\"KEY-2\\\"}\"}", MediaType.APPLICATION_JSON));
+
+ service.capture("stoptb1", "pw", false);
+ String fileBefore = Files.readString(tokenDir.resolve("central-refresh-token"));
+
+ assertEquals("SESSION-KEY", service.getCentralSessionKey());
+ assertFalse(fileBefore.equals(Files.readString(tokenDir.resolve("central-refresh-token"))),
+ "rotated refresh token must be saved");
+ assertEquals("KEY-2", service.getCentralSessionKey());
+ central.verify();
+ }
+
+ @Test
+ void revokedTokenIsDeletedAndReconnectNeeded() {
+ expectLoginOk("RT-1");
+ central.expect(requestTo(REFRESH)).andRespond(withStatus(HttpStatus.UNAUTHORIZED));
+
+ service.capture("stoptb1", "pw", false);
+
+ assertNull(assertDoesNotThrowValue());
+ assertFalse(service.hasToken());
+ assertFalse(Files.exists(tokenDir.resolve("central-refresh-token")));
+ assertEquals(CentralAuthService.State.RECONNECT_NEEDED, service.getState());
+ central.verify();
+ }
+
+ @Test
+ void noTokenMeansNoCentralCall() throws Exception {
+ central.expect(ExpectedCount.never(), requestTo(REFRESH));
+ assertNull(service.getCentralSessionKey());
+ central.verify();
+ }
+
+ private String assertDoesNotThrowValue() {
+ try {
+ return service.getCentralSessionKey();
+ } catch (Exception e) {
+ throw new AssertionError("revoked token must not throw", e);
+ }
+ }
+
+ @Test
+ void rejectedPasswordIsRememberedAfterRestart() {
+ expectLoginRejected();
+ service.capture("stoptb1", "wrong", false);
+ central.verify();
+
+ CentralAuthService restarted = newService(true, 403);
+ // laptop login after the restart must not reach central with the rejected password
+ assertFalse(restarted.capture("stoptb1", "wrong", false));
+ central.verify();
+ }
+
+ @Test
+ void successfulReconnectClearsTheRejectedMarker() {
+ expectLoginRejected();
+ expectLoginOk("RT-9");
+ service.capture("stoptb1", "wrong", false);
+ assertTrue(service.capture("stoptb1", "central-pw", true));
+ assertFalse(Files.exists(tokenDir.resolve("central-login-rejected")));
+ central.verify();
+ }
+
+ @Test
+ void rejectedMarkerHoldsNoPassword() throws Exception {
+ expectLoginRejected();
+ service.capture("stoptb1", "secret-pw", false);
+ String marker = Files.readString(tokenDir.resolve("central-login-rejected"));
+ assertEquals("stoptb1", marker.trim());
+ assertFalse(marker.contains("secret-pw"));
+ }
+}
diff --git a/src/test/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncServiceTest.java b/src/test/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncServiceTest.java
new file mode 100644
index 00000000..07fa285f
--- /dev/null
+++ b/src/test/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncServiceTest.java
@@ -0,0 +1,176 @@
+/*
+* 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.dataSyncActivity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.json.JSONObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import com.iemr.mmu.service.benid.BenIdAutoTopUpService;
+import com.iemr.mmu.service.benid.CentralAuthService;
+
+class AutoSyncServiceTest {
+
+ private AutoSyncService service;
+ private CentralAuthService centralAuth;
+ private UploadDataToServerImpl upload;
+ private DownloadDataFromServerImpl download;
+ private DataSyncRepository repo;
+ private BenIdAutoTopUpService benId;
+ private DownloadDataFromServerTransactionalImpl inventory;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ centralAuth = mock(CentralAuthService.class);
+ upload = mock(UploadDataToServerImpl.class);
+ download = mock(DownloadDataFromServerImpl.class);
+ repo = mock(DataSyncRepository.class);
+ benId = mock(BenIdAutoTopUpService.class);
+ inventory = mock(DownloadDataFromServerTransactionalImpl.class);
+ when(centralAuth.isEnabled()).thenReturn(true);
+ when(centralAuth.getVanID()).thenReturn(403);
+ when(centralAuth.getConnectedUser()).thenReturn("stoptb1");
+ when(centralAuth.getCentralSessionKey()).thenReturn("SESSION-KEY");
+ when(repo.getVanProviderServiceMapID(403)).thenReturn(1726);
+
+ service = new AutoSyncService();
+ ReflectionTestUtils.setField(service, "centralAuthService", centralAuth);
+ ReflectionTestUtils.setField(service, "uploadDataToServerImpl", upload);
+ ReflectionTestUtils.setField(service, "downloadDataFromServerImpl", download);
+ ReflectionTestUtils.setField(service, "dataSyncRepository", repo);
+ ReflectionTestUtils.setField(service, "benIdAutoTopUpService", benId);
+ ReflectionTestUtils.setField(service, "downloadDataFromServerTransactional", inventory);
+ ReflectionTestUtils.setField(service, "inventoryDownloadEnabled", true);
+ ReflectionTestUtils.setField(service, "uploadEnabled", true);
+ ReflectionTestUtils.setField(service, "masterDownloadEnabled", true);
+ ReflectionTestUtils.setField(service, "masterDownloadInventoryOnly", true);
+ }
+
+ @Test
+ void uploadUsesCentralKeyAndConnectedOperator() throws Exception {
+ when(upload.getDataToSyncToServer(403, "stoptb1", "SESSION-KEY", null)).thenReturn("Data successfully synced");
+
+ service.uploadOnce();
+
+ verify(upload).getDataToSyncToServer(403, "stoptb1", "SESSION-KEY", null);
+ verify(benId).triggerNow();
+ assertEquals("Data successfully synced", service.status().get("lastUploadResult"));
+ }
+
+ @Test
+ void uploadIsFollowedByInventoryDownload() throws Exception {
+ when(upload.getDataToSyncToServer(403, "stoptb1", "SESSION-KEY", null)).thenReturn("Data successfully synced");
+ when(inventory.downloadTransactionalData(403, "SESSION-KEY", "datasync")).thenReturn(1);
+
+ service.uploadOnce();
+
+ verify(inventory).downloadTransactionalData(403, "SESSION-KEY", "datasync");
+ assertEquals("success", service.status().get("lastInventoryDownloadResult"));
+ }
+
+ @Test
+ void inventoryDownloadFailureKeepsUploadResult() throws Exception {
+ when(upload.getDataToSyncToServer(403, "stoptb1", "SESSION-KEY", null)).thenReturn("Data successfully synced");
+ when(inventory.downloadTransactionalData(403, "SESSION-KEY", "datasync")).thenThrow(new RuntimeException("offline"));
+
+ service.uploadOnce();
+
+ assertEquals("Data successfully synced", service.status().get("lastUploadResult"));
+ assertEquals("Inventory download: offline", service.status().get("lastError"));
+ }
+
+ @Test
+ void noCentralConnectionSkipsUpload() throws Exception {
+ when(centralAuth.getCentralSessionKey()).thenReturn(null);
+
+ service.uploadOnce();
+
+ verify(upload, never()).getDataToSyncToServer(anyInt(), anyString(), anyString(), any());
+ assertEquals("No central connection - waiting for a user login", service.status().get("lastError"));
+ }
+
+ @Test
+ void masterDownloadUsesVanServiceLineAndOnlyInventoryMasters() throws Exception {
+ when(download.downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, AutoSyncService.INVENTORY_MASTERS))
+ .thenReturn("success");
+
+ service.masterDownloadOnce();
+
+ verify(download).downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, AutoSyncService.INVENTORY_MASTERS);
+ assertEquals("success", service.status().get("lastMasterDownloadResult"));
+ }
+
+ @Test
+ void masterDownloadCanFetchAllMasters() throws Exception {
+ ReflectionTestUtils.setField(service, "masterDownloadInventoryOnly", false);
+ when(download.downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, null)).thenReturn("success");
+
+ service.masterDownloadOnce();
+
+ verify(download).downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, null);
+ }
+
+ @Test
+ void manualMasterDownloadRunningIsNotCountedAsARun() throws Exception {
+ when(download.downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, AutoSyncService.INVENTORY_MASTERS))
+ .thenReturn("inProgress");
+
+ service.masterDownloadOnce();
+
+ assertEquals(0L, service.status().getLong("lastMasterDownloadAt"));
+ }
+
+ @Test
+ void statusSumsRejectedRows() throws Exception {
+ when(repo.getSyncFailures()).thenReturn(List.of(
+ Map.of("tableName", "t_patientissue", "reason", "Duplicate", "failedRows", 2L),
+ Map.of("tableName", "t_prescription", "reason", "Timeout", "failedRows", 3L)));
+
+ JSONObject status = service.status();
+
+ assertEquals(5L, status.getLong("failedRows"));
+ assertEquals(2, status.getJSONArray("failures").length());
+ }
+
+ @Test
+ void disabledLaptopDoesNothing() throws Exception {
+ when(centralAuth.isEnabled()).thenReturn(false);
+
+ service.scheduledUpload();
+ service.scheduledMasterDownload();
+
+ verify(centralAuth, never()).getCentralSessionKey();
+ }
+}
diff --git a/src/test/java/com/iemr/mmu/service/dataSyncActivity/DownloadMastersFullForLineTest.java b/src/test/java/com/iemr/mmu/service/dataSyncActivity/DownloadMastersFullForLineTest.java
new file mode 100644
index 00000000..d92182b2
--- /dev/null
+++ b/src/test/java/com/iemr/mmu/service/dataSyncActivity/DownloadMastersFullForLineTest.java
@@ -0,0 +1,49 @@
+package com.iemr.mmu.service.dataSyncActivity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.sql.Timestamp;
+
+import org.junit.jupiter.api.Test;
+
+import com.iemr.mmu.data.syncActivity_syncLayer.SyncDownloadMaster;
+
+class DownloadMastersFullForLineTest {
+
+ private static final Timestamp COPIED_DATE = Timestamp.valueOf("2026-10-16 13:17:31");
+
+ private static SyncDownloadMaster table(String masterType) {
+ SyncDownloadMaster t = new SyncDownloadMaster();
+ t.setMasterType(masterType);
+ t.setLastDownloadDate(COPIED_DATE);
+ return t;
+ }
+
+ @Test
+ void serviceLineMastersAreDownloadedInFull() {
+ SyncDownloadMaster parkingPlace = table("P");
+
+ DownloadDataFromServerImpl.fullDownloadForLineAndVanMasters(parkingPlace);
+
+ assertNull(parkingPlace.getLastDownloadDate());
+ }
+
+ @Test
+ void vanMastersAreDownloadedInFull() {
+ SyncDownloadMaster vanServicePoint = table("v");
+
+ DownloadDataFromServerImpl.fullDownloadForLineAndVanMasters(vanServicePoint);
+
+ assertNull(vanServicePoint.getLastDownloadDate());
+ }
+
+ @Test
+ void mastersSharedByAllLinesStayIncremental() {
+ SyncDownloadMaster villages = table("A");
+
+ DownloadDataFromServerImpl.fullDownloadForLineAndVanMasters(villages);
+
+ assertEquals(COPIED_DATE, villages.getLastDownloadDate());
+ }
+}
diff --git a/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadRenamedColumnTest.java b/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadRenamedColumnTest.java
new file mode 100644
index 00000000..adbc39de
--- /dev/null
+++ b/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadRenamedColumnTest.java
@@ -0,0 +1,48 @@
+package com.iemr.mmu.service.dataSyncActivity;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+class UploadRenamedColumnTest {
+
+ private static final String VAN = "status,error_message,reason_for_refusal,push_response_json,"
+ + "date_format(last_mod_date,'%Y-%m-%d %H:%i:%s'),vanSerialNo";
+ private static final String SERVER = "status,error_message,reason_for_refusal,push_response_json,last_mod_date,vanSerialNo";
+
+ @Test
+ void oldNameInSyncListUsesTheTableName() {
+ Set table = Set.of("status", "error_message", "reason_to_close", "push_response_json", "last_mod_date",
+ "vanserialno");
+
+ String[] cols = UploadDataToServerImpl.useRenamedColumns("tb_diagnostic_order", VAN, SERVER, table);
+
+ assertArrayEquals(new String[] { VAN.replace("reason_for_refusal", "reason_to_close"),
+ SERVER.replace("reason_for_refusal", "reason_to_close") }, cols);
+ }
+
+ @Test
+ void tableStillWithOldNameIsUnchanged() {
+ Set table = Set.of("status", "error_message", "reason_for_refusal", "push_response_json");
+
+ assertArrayEquals(new String[] { VAN, SERVER },
+ UploadDataToServerImpl.useRenamedColumns("tb_diagnostic_order", VAN, SERVER, table));
+ }
+
+ @Test
+ void newNameInSyncListOnOlderTable() {
+ String van = "status,reason_to_close", server = "status,reason_to_close";
+
+ assertArrayEquals(new String[] { "status,reason_for_refusal", "status,reason_for_refusal" },
+ UploadDataToServerImpl.useRenamedColumns("tb_diagnostic_order", van, server,
+ Set.of("status", "reason_for_refusal")));
+ }
+
+ @Test
+ void otherTablesAreUnchanged() {
+ assertArrayEquals(new String[] { VAN, SERVER }, UploadDataToServerImpl.useRenamedColumns("t_tbsuspected", VAN,
+ SERVER, Set.of("reason_to_close")));
+ }
+}
diff --git a/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadSkipDuplicateGroupTest.java b/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadSkipDuplicateGroupTest.java
new file mode 100644
index 00000000..438922a5
--- /dev/null
+++ b/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadSkipDuplicateGroupTest.java
@@ -0,0 +1,51 @@
+package com.iemr.mmu.service.dataSyncActivity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+import com.iemr.mmu.data.syncActivity_syncLayer.SyncUtilityClass;
+
+class UploadSkipDuplicateGroupTest {
+
+ private static SyncUtilityClass table(int group, String name) {
+ SyncUtilityClass t = new SyncUtilityClass();
+ t.setSyncTableGroupID(group);
+ t.setTableName(name);
+ return t;
+ }
+
+ private static List names(List tables) {
+ return tables.stream().map(SyncUtilityClass::getTableName).collect(Collectors.toList());
+ }
+
+ // as on the laptops: inventory tables in group 5 (old lists) and group 9 (complete)
+ private final List all = List.of(table(4, "t_Prescription"), table(5, "t_Lab_TestResult"),
+ table(5, "t_ItemStockEntry"), table(5, "t_ItemStockExit"), table(5, "t_PatientIssue"),
+ table(9, "t_itemstockentry"), table(9, "t_itemstockexit"), table(9, "t_patientissue"),
+ table(9, "t_stockadjustment"));
+
+ @Test
+ void inventoryTablesAreSkippedInGroup5() {
+ List group5 = all.stream().filter(t -> t.getSyncTableGroupID() == 5).collect(Collectors.toList());
+
+ assertEquals(List.of("t_Lab_TestResult"), names(UploadDataToServerImpl.skipTablesUploadedByLaterGroup(group5, 5, all)));
+ }
+
+ @Test
+ void group9StillUploadsThem() {
+ List group9 = all.stream().filter(t -> t.getSyncTableGroupID() == 9).collect(Collectors.toList());
+
+ assertEquals(names(group9), names(UploadDataToServerImpl.skipTablesUploadedByLaterGroup(group9, 9, all)));
+ }
+
+ @Test
+ void tablesInOneGroupAreUnchanged() {
+ List group4 = List.of(table(4, "t_Prescription"));
+
+ assertEquals(List.of("t_Prescription"), names(UploadDataToServerImpl.skipTablesUploadedByLaterGroup(group4, 4, all)));
+ }
+}
diff --git a/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanKeysTest.java b/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanKeysTest.java
new file mode 100644
index 00000000..23a2a513
--- /dev/null
+++ b/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanKeysTest.java
@@ -0,0 +1,71 @@
+package com.iemr.mmu.service.dataSyncLayerCentral;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+class GetDataFromVanAndSyncToDBVanKeysTest {
+
+ // server list of t_itemstockentry (van list has ItemStockEntryID in VanSerialNo's place)
+ private static final List STOCK_ENTRY_SERVER = Arrays.asList("FacilityID", "ItemID", "BatchNo",
+ "QuantityInHand", "VanSerialNo", "VanID", "SyncFacilityID");
+
+ @Test
+ void vanSerialNoTakesTheVanRowIdWhenNotSentByName() {
+ Map record = new HashMap<>(Map.of("ItemStockEntryID", 6, "FacilityID", 428, "VanID", 428));
+
+ GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, STOCK_ENTRY_SERVER, "ItemStockEntryID");
+
+ assertEquals(6, record.get("VanSerialNo"));
+ }
+
+ @Test
+ void syncFacilityIdTakesTheRowStoreWhenNotSent() {
+ Map record = new HashMap<>(Map.of("ItemStockExitID", 9, "FacilityID", 428));
+
+ GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, Arrays.asList("ItemStockEntryID", "FacilityID",
+ " VanSerialNo", "SyncFacilityID"), "ItemStockExitID");
+
+ assertEquals(428, record.get("SyncFacilityID"));
+ assertEquals(9, record.get("VanSerialNo"));
+ }
+
+ @Test
+ void valuesSentByTheVanAreKept() {
+ Map record = new HashMap<>(
+ Map.of("PatientIssueID", 3, "VanSerialNo", 3, "FacilityID", 428, "SyncFacilityID", 428));
+
+ GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, Arrays.asList("VanSerialNo", "SyncFacilityID"),
+ "PatientIssueID");
+
+ assertEquals(3, record.get("VanSerialNo"));
+ assertEquals(428, record.get("SyncFacilityID"));
+ }
+
+ @Test
+ void columnsNotOnTheServerAreNotAdded() {
+ Map record = new HashMap<>(Map.of("BenVisitID", 1991, "VanID", 428));
+
+ GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, Arrays.asList("BeneficiaryRegID", "VanSerialNo", "VanID"),
+ "BenVisitID");
+
+ assertEquals(1991, record.get("VanSerialNo"));
+ assertFalse(record.containsKey("SyncFacilityID"));
+ }
+
+ @Test
+ void missingVanIdLeavesVanSerialNoEmpty() {
+ Map record = new HashMap<>(Map.of("FacilityID", 428));
+
+ GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, STOCK_ENTRY_SERVER, "ItemStockEntryID");
+
+ assertNull(record.get("VanSerialNo"));
+ }
+}
diff --git a/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanLinksTest.java b/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanLinksTest.java
new file mode 100644
index 00000000..0b5e6017
--- /dev/null
+++ b/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanLinksTest.java
@@ -0,0 +1,101 @@
+package com.iemr.mmu.service.dataSyncLayerCentral;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+class GetDataFromVanAndSyncToDBVanLinksTest {
+
+ private GetDataFromVanAndSyncToDBImpl service;
+ private DataSyncRepositoryCentral repo;
+
+ @BeforeEach
+ void setUp() {
+ service = new GetDataFromVanAndSyncToDBImpl();
+ repo = mock(DataSyncRepositoryCentral.class);
+ ReflectionTestUtils.setField(service, "dataSyncRepositoryCentral", repo);
+ }
+
+ private static Map row(Object... kv) {
+ Map m = new HashMap<>();
+ for (int i = 0; i < kv.length; i += 2)
+ m.put((String) kv[i], kv[i + 1]);
+ return m;
+ }
+
+ @Test
+ void prescriptionGetsTheCentralIdOfItsVisit() {
+ // laptop visit 1261 of van 428 is central visit 5003 (central's own 1261 is another van's visit)
+ when(repo.findCentralIdOfVanRow("db_iemr", "t_benvisitdetail", "BenVisitID", 1261, "428")).thenReturn(5003L);
+ Map rx = row("PrescriptionID", 378, "BenVisitID", 1261, "VanID", 428);
+
+ String error = service.translateVanLinks("db_iemr", "t_prescription", rx,
+ List.of("BenVisitID", "VanSerialNo", "VanID"), "428");
+
+ assertNull(error);
+ assertEquals(5003L, rx.get("BenVisitID"));
+ assertEquals(378, rx.get("PrescriptionID")); // own ID of t_prescription is not a link
+ }
+
+ @Test
+ void prescribedDrugGetsCentralVisitAndPrescription() {
+ when(repo.findCentralIdOfVanRow("db_iemr", "t_benvisitdetail", "BenVisitID", 1261, "428")).thenReturn(5003L);
+ when(repo.findCentralIdOfVanRow("db_iemr", "t_prescription", "PrescriptionID", 378, "428")).thenReturn(9001L);
+ Map drug = row("PrescribedDrugID", 5, "BenVisitID", 1261, "PrescriptionID", 378);
+
+ assertNull(service.translateVanLinks("db_iemr", "t_prescribeddrug", drug,
+ Arrays.asList("BenVisitID", "PrescriptionID", "VanSerialNo"), "428"));
+
+ assertEquals(5003L, drug.get("BenVisitID"));
+ assertEquals(9001L, drug.get("PrescriptionID"));
+ }
+
+ @Test
+ void parentNotOnCentralYetIsNotSaved() {
+ when(repo.findCentralIdOfVanRow(anyString(), anyString(), anyString(), any(), anyString())).thenReturn(null);
+ Map rx = row("BenVisitID", 1991);
+
+ String error = service.translateVanLinks("db_iemr", "t_prescription", rx, List.of("BenVisitID"), "428");
+
+ assertNotNull(error);
+ assertEquals(1991, rx.get("BenVisitID")); // left as it was, row not saved
+ }
+
+ @Test
+ void visitItselfAndRowsWithoutLinksAreUnchanged() {
+ Map visit = row("BenVisitID", 1261, "VisitCode", 10042800001261L);
+ assertNull(service.translateVanLinks("db_iemr", "t_benvisitdetail", visit, List.of("BenVisitID"), "428"));
+ assertEquals(1261, visit.get("BenVisitID"));
+
+ Map batch = row("ItemStockEntryID", 41, "FacilityID", 428);
+ assertNull(service.translateVanLinks("db_iemr", "t_itemstockentry", batch, List.of("FacilityID"), "428"));
+
+ Map noVan = row("BenVisitID", 1261);
+ assertNull(service.translateVanLinks("db_iemr", "t_prescription", noVan, List.of("BenVisitID"), null));
+ verify(repo, never()).findCentralIdOfVanRow(anyString(), anyString(), anyString(), any(), anyString());
+ }
+
+ @Test
+ void linkColumnNotUploadedIsLeftAlone() {
+ Map issue = row("PrescriptionID", 378);
+
+ assertNull(service.translateVanLinks("db_iemr", "t_patientissue", issue, List.of("VisitCode"), "428"));
+
+ verify(repo, never()).findCentralIdOfVanRow(anyString(), anyString(), anyString(), any(), anyString());
+ }
+}