Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.Parameter;
import org.hibernate.annotations.Type;
import org.openelisglobal.analyzer.valueholder.QcFrequencyType;
import org.openelisglobal.common.hibernateConverter.StringListConverter;
import org.openelisglobal.common.valueholder.BaseObject;
import org.openelisglobal.hibernate.converter.StringToIntegerConverter;
Expand Down Expand Up @@ -439,11 +440,53 @@ public String ensureFhirUuid() {
return fhirUuid.toString();
}


// ── Manual QC configuration fields (Issue #3490) ─────────────────────────

/**
* How often QC must be performed on this analyzer.
* Null means no schedule has been configured yet.
* Maps to QcFrequencyType: DAILY | PER_SHIFT | CUSTOM_HOURS
*/
@Column(name = "qc_frequency_type", length = 20)
@Enumerated(EnumType.STRING)
private QcFrequencyType qcFrequencyType;

/**
* Number of hours between required QC runs.
* Used with PER_SHIFT (default 8 if null) and CUSTOM_HOURS frequency types.
*/
@Column(name = "qc_frequency_hours")
private Integer qcFrequencyHours;

/**
* When true: QC must pass before analyzer results can be released.
* When false: QC status is informational only (default).
*/
@Column(name = "qc_required", nullable = false)
private boolean qcRequired = false;

/**
* Enum for analyzer unified status field. Values must match database
* constraint: INACTIVE, SETUP, VALIDATION, ACTIVE, ERROR_PENDING, OFFLINE,
* DELETED, PENDING_REGISTRATION
*/

// ── QC config accessors (Issue #3490) ────────────────────────────────────

public QcFrequencyType getQcFrequencyType() { return qcFrequencyType; }
public void setQcFrequencyType(QcFrequencyType qcFrequencyType) {
this.qcFrequencyType = qcFrequencyType;
}

public Integer getQcFrequencyHours() { return qcFrequencyHours; }
public void setQcFrequencyHours(Integer qcFrequencyHours) {
this.qcFrequencyHours = qcFrequencyHours;
}

public boolean isQcRequired() { return qcRequired; }
public void setQcRequired(boolean qcRequired) { this.qcRequired = qcRequired; }

public enum AnalyzerStatus {
INACTIVE, SETUP, VALIDATION, ACTIVE, ERROR_PENDING, OFFLINE, DELETED, PENDING_REGISTRATION
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.openelisglobal.analyzer.valueholder;

public enum QcFrequencyType {
DAILY,
PER_SHIFT,
CUSTOM_HOURS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.openelisglobal.analyzer.valueholder;


public enum QcStatus {
PASS,
OVERDUE,
FAILED,
NOT_RUN
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package org.openelisglobal.analyzerqc.controller;

import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import org.openelisglobal.analyzerqc.form.QcRunForm;
import org.openelisglobal.analyzerqc.service.AnalyzerQcService;
import org.openelisglobal.analyzerqc.valueholder.AnalyzerQcRun;
import org.openelisglobal.analyzerqc.valueholder.AnalyzerQcStatus;
import org.openelisglobal.common.log.LogEvent;
import org.openelisglobal.login.valueholder.UserSessionData;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;

/**
* REST endpoints for Analyzer Manual QC Recording (Issue #3490).
*/
@RestController
@RequestMapping("/rest/analyzer")
public class AnalyzerQcRestController {
Comment thread
karansahani78 marked this conversation as resolved.

@Autowired
private AnalyzerQcService analyzerQcService;

@GetMapping(
value = "/{id}/qc-status",
produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('ANALYZER_VIEW')")
public ResponseEntity<AnalyzerQcStatus> getQcStatus(@PathVariable String id) {
try {
return ResponseEntity.ok(analyzerQcService.getQcStatus(id));
} catch (org.openelisglobal.common.exception.LIMSRuntimeException e) {
LogEvent.logWarn(getClass().getName(), "getQcStatus",
"Analyzer not found: " + id);
return ResponseEntity.notFound().build();
} catch (Exception e) {
LogEvent.logError(getClass().getName(), "getQcStatus", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
Comment thread
karansahani78 marked this conversation as resolved.
}

@PostMapping(
value = "/{id}/qc-runs",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('QC_RESULT_ENTER')")
public ResponseEntity<AnalyzerQcStatus> recordQcRun(
@PathVariable String id,
@RequestBody QcRunForm form,
HttpServletRequest request) {
try {
analyzerQcService.recordQcRun(id, form, getSysUserId(request));
return ResponseEntity
.status(HttpStatus.CREATED)
.body(analyzerQcService.getQcStatus(id));
} catch (IllegalArgumentException e) {
LogEvent.logWarn(getClass().getName(), "recordQcRun", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
LogEvent.logError(getClass().getName(), "recordQcRun", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
Comment thread
karansahani78 marked this conversation as resolved.
}

@GetMapping(
value = "/{id}/qc-runs",
produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('QC_HISTORY_VIEW')")
public ResponseEntity<List<AnalyzerQcRun>> getQcHistory(@PathVariable String id) {
try {
return ResponseEntity.ok(analyzerQcService.getQcHistory(id));
} catch (Exception e) {
LogEvent.logError(getClass().getName(), "getQcHistory", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
Comment thread
karansahani78 marked this conversation as resolved.
}

private String getSysUserId(HttpServletRequest request) {
UserSessionData sessionData = (UserSessionData)
request.getSession().getAttribute("userSessionData");
return sessionData != null
? String.valueOf(sessionData.getSystemUserId())
: null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.openelisglobal.analyzerqc.dao;

import java.util.List;
import java.util.Optional;
import org.openelisglobal.analyzerqc.valueholder.AnalyzerQcRun;
import org.openelisglobal.common.dao.BaseDAO;

/**
* Persistence operations for AnalyzerQcRun.
*
* Extends BaseDAO following the same pattern as AnalyzerQcRuleDAO,
* with additional query methods needed for QC status evaluation.
*/
public interface AnalyzerQcRunDAO extends BaseDAO<AnalyzerQcRun, String> {

/**
* Returns the most recent PASS run for an analyzer.
* Used by status evaluation to determine if QC is still valid.
*/
Optional<AnalyzerQcRun> getLastPassForAnalyzer(String analyzerId);

/**
* Returns the most recent run of any result for an analyzer.
* Used to display "last run" info in the UI status panel.
*/
Optional<AnalyzerQcRun> getLastRunForAnalyzer(String analyzerId);

/**
* Returns all runs for an analyzer, most recent first.
* Used by the history endpoint.
*/
List<AnalyzerQcRun> getAllRunsForAnalyzer(String analyzerId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package org.openelisglobal.analyzerqc.daoimpl;

import java.util.List;
import java.util.Optional;
import org.openelisglobal.analyzerqc.dao.AnalyzerQcRunDAO;
import org.openelisglobal.analyzerqc.valueholder.AnalyzerQcRun;
import org.openelisglobal.common.daoimpl.BaseDAOImpl;
import org.openelisglobal.common.exception.LIMSRuntimeException;
import org.openelisglobal.common.log.LogEvent;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

/**
* Hibernate-backed DAO for AnalyzerQcRun.
* Extends BaseDAOImpl — same pattern as AnalyzerExperimentDAOImpl.
*
* Note: schemaName omitted in JPQL because analyzer_qc_run table
* has no schema prefix (matches analyzer_qc_rule pattern).
*/
@Component
@Transactional
public class AnalyzerQcRunDAOImpl
extends BaseDAOImpl<AnalyzerQcRun, String>
implements AnalyzerQcRunDAO {

public AnalyzerQcRunDAOImpl() {
super(AnalyzerQcRun.class);
}

@Override
public Optional<AnalyzerQcRun> getLastPassForAnalyzer(String analyzerId) {
try {
List<AnalyzerQcRun> results = entityManager
.createQuery(
"FROM AnalyzerQcRun r " +
"WHERE r.analyzer.id = :analyzerId " +
" AND r.result = 'PASS' " +
"ORDER BY r.runDate DESC",
AnalyzerQcRun.class)
.setParameter("analyzerId", analyzerId)
.setMaxResults(1)
.getResultList();
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
} catch (Exception e) {
LogEvent.logError(getClass().getName(), "getLastPassForAnalyzer", e.getMessage());
throw new LIMSRuntimeException("Error in AnalyzerQcRunDAO.getLastPassForAnalyzer", e);
}
}

@Override
public Optional<AnalyzerQcRun> getLastRunForAnalyzer(String analyzerId) {
try {
List<AnalyzerQcRun> results = entityManager
.createQuery(
"FROM AnalyzerQcRun r " +
"WHERE r.analyzer.id = :analyzerId " +
"ORDER BY r.runDate DESC",
AnalyzerQcRun.class)
.setParameter("analyzerId", analyzerId)
.setMaxResults(1)
.getResultList();
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
} catch (Exception e) {
LogEvent.logError(getClass().getName(), "getLastRunForAnalyzer", e.getMessage());
throw new LIMSRuntimeException("Error in AnalyzerQcRunDAO.getLastRunForAnalyzer", e);
}
}

@Override
public List<AnalyzerQcRun> getAllRunsForAnalyzer(String analyzerId) {
try {
return entityManager
.createQuery(
"FROM AnalyzerQcRun r " +
"WHERE r.analyzer.id = :analyzerId " +
"ORDER BY r.runDate DESC",
AnalyzerQcRun.class)
.setParameter("analyzerId", analyzerId)
.getResultList();
} catch (Exception e) {
LogEvent.logError(getClass().getName(), "getAllRunsForAnalyzer", e.getMessage());
throw new LIMSRuntimeException("Error in AnalyzerQcRunDAO.getAllRunsForAnalyzer", e);
}
}
}
39 changes: 39 additions & 0 deletions src/main/java/org/openelisglobal/analyzerqc/form/QcRunForm.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.openelisglobal.analyzerqc.form;

import java.sql.Timestamp;

/**
* Request body for POST /rest/analyzers/{id}/qc-runs.
*
* Fields:
* result — required: "PASS" or "FAIL"
* value — optional: numeric or freetext measurement
* runDate — optional: defaults to now() if null
* performedByUserId — optional: defaults to session user if null
* source — required: "ANALYZER_IMPORT" | "ANALYZER_LIST"
*/
public class QcRunForm {

private String result;
private String value;
private Timestamp runDate;
private String performedByUserId;
private String source;

public String getResult() { return result; }
public void setResult(String result) { this.result = result; }

public String getValue() { return value; }
public void setValue(String value) { this.value = value; }

public Timestamp getRunDate() { return runDate; }
public void setRunDate(Timestamp runDate) { this.runDate = runDate; }

public String getPerformedByUserId() { return performedByUserId; }
public void setPerformedByUserId(String performedByUserId) {
this.performedByUserId = performedByUserId;
}

public String getSource() { return source; }
public void setSource(String source) { this.source = source; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.openelisglobal.analyzerqc.service;

import java.util.List;
import org.openelisglobal.analyzerqc.form.QcRunForm;
import org.openelisglobal.analyzerqc.valueholder.AnalyzerQcRun;
import org.openelisglobal.analyzerqc.valueholder.AnalyzerQcStatus;

/**
* Business logic for Analyzer Manual QC Recording (Issue #3490).
*/
public interface AnalyzerQcService {

/**
* Returns the current QC validity status for an analyzer.
* Implements BR-AQC-001, BR-AQC-002, BR-AQC-004.
*
* @param analyzerId the analyzer's String ID
*/
AnalyzerQcStatus getQcStatus(String analyzerId);

/**
* Records a new manual QC run.
* Implements FR-AQC-012, BR-AQC-006.
*
* @param analyzerId the analyzer's String ID
* @param form request body
* @param currentUserId the authenticated user's sysUserId
*/
void recordQcRun(String analyzerId, QcRunForm form, String currentUserId);

/**
* Returns all QC runs for an analyzer, newest first.
*/
List<AnalyzerQcRun> getQcHistory(String analyzerId);
}
Loading
Loading