From 210bde77aa85cf3fd3f18ab76c1fbc6f195c9e28 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 11 Sep 2026 13:06:12 -0600 Subject: [PATCH 1/8] Move conception records from the nbri_ehr schema to a study dataset The dam becomes the participant and the conception date the visit date, so the shared EHR trigger announces the modified dam and the hand-rolled QC state metadata goes away. Existing rows are not migrated, and the unique constraint on the conception Id is now enforced by the trigger script. --- .../resources/queries/nbri_ehr/Conception.js | 45 -------- .../queries/nbri_ehr/Conception.query.xml | 106 ------------------ .../ConceptionsByDam.query.xml | 0 .../{nbri_ehr => study}/ConceptionsByDam.sql | 20 ++-- .../queries/study/activeConceptions.sql | 10 +- nbri_ehr/resources/queries/study/birth.js | 4 +- .../resources/queries/study/birth.query.xml | 6 +- .../resources/queries/study/conception.js | 45 ++++++++ .../queries/study/conception.query.xml | 29 +++++ nbri_ehr/resources/queries/study/pregnancy.js | 4 +- .../queries/study/pregnancy.query.xml | 6 +- .../study/datasets/datasets_manifest.xml | 1 + .../study/datasets/datasets_metadata.xml | 36 ++++++ .../resources/reports/additionalReports.tsv | 2 +- .../postgresql/nbri_ehr-26.002-26.003.sql | 8 ++ nbri_ehr/resources/schemas/nbri_ehr.xml | 24 ---- .../web/nbri_ehr/model/sources/Conception.js | 45 ++++---- .../web/nbri_ehr/panel/SnapshotPanel.js | 8 +- .../window/StartWithConceptionWindow.js | 16 +-- .../section/NBRIConceptionFormSection.java | 2 +- ...ActiveConceptionsDemographicsProvider.java | 10 +- .../nbri_ehr/query/NBRI_EHRTriggerHelper.java | 9 +- .../nbri_ehr/table/NBRI_EHRCustomizer.java | 2 +- .../tests.nbri_ehr/NBRI_EHRTest.java | 56 ++++----- 24 files changed, 217 insertions(+), 277 deletions(-) delete mode 100644 nbri_ehr/resources/queries/nbri_ehr/Conception.js delete mode 100644 nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml rename nbri_ehr/resources/queries/{nbri_ehr => study}/ConceptionsByDam.query.xml (100%) rename nbri_ehr/resources/queries/{nbri_ehr => study}/ConceptionsByDam.sql (83%) create mode 100644 nbri_ehr/resources/queries/study/conception.js create mode 100644 nbri_ehr/resources/queries/study/conception.query.xml create mode 100644 nbri_ehr/resources/schemas/dbscripts/postgresql/nbri_ehr-26.002-26.003.sql diff --git a/nbri_ehr/resources/queries/nbri_ehr/Conception.js b/nbri_ehr/resources/queries/nbri_ehr/Conception.js deleted file mode 100644 index 9dd9a9c..0000000 --- a/nbri_ehr/resources/queries/nbri_ehr/Conception.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2026 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -require("ehr/triggers").initScript(this); - -var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); - -// the shared trigger collects modified participants from row.Id, which this table does not have, so announce the dams -// here or their cached activeConceptions keeps a stale Pregnant value -var damsModified = []; - -function addDam(dam) { - if (dam && damsModified.indexOf(dam) === -1) { - damsModified.push(dam); - } -} - -EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.INIT, 'nbri_ehr', 'Conception', function(event, helper){ - // the script scope can outlive a single save, so never inherit dams from a prior one - damsModified = []; -}); - -EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_UPSERT, 'nbri_ehr', 'Conception', function(helper, scriptErrors, row, oldRow) { - if (helper.isValidateOnly()) - return; - - addDam(row.Dam); - - // a re-pointed conception frees the dam it used to belong to - addDam(oldRow ? oldRow.Dam : null); -}); - -EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_DELETE, 'nbri_ehr', 'Conception', function(helper, scriptErrors, row) { - // the row LabKey passes for a delete can carry keys only, and the record is still readable at this point - addDam(row.Dam || triggerHelper.getConceptionDam(row.ConceptId)); -}); - -EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.COMPLETE, 'nbri_ehr', 'Conception', function(event, errors, helper){ - if (damsModified.length) { - triggerHelper.reportDataChange('nbri_ehr', 'Conception', damsModified); - damsModified = []; - } -}); diff --git a/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml b/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml deleted file mode 100644 index df0d1ca..0000000 --- a/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - Conception Records - - - true - - - Conception Id - true - - - Conception Date - true - - - Estimated - Conception date is estimated rather than observed - - - true - - - - Task Id - ALWAYS_OFF - - ehr - tasks - taskid - rowid - - /ehr/dataEntryFormDetails.view?formType=${TaskId/formtype}&taskid=${TaskId} - - - Status - - core - qcstate - rowid - - - - - - - FBEC5D - - - - - - FBEC5D - - - - - - FBEC5D - - - - - - FF0000 - - - - - - FBEC5D - - - - - - FBEC5D - - - - - - FF0000 - - - - - - FBEC5D - - - - - - FBEC5D - - - - -
-
-
-
\ No newline at end of file diff --git a/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.query.xml b/nbri_ehr/resources/queries/study/ConceptionsByDam.query.xml similarity index 100% rename from nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.query.xml rename to nbri_ehr/resources/queries/study/ConceptionsByDam.query.xml diff --git a/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql b/nbri_ehr/resources/queries/study/ConceptionsByDam.sql similarity index 83% rename from nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql rename to nbri_ehr/resources/queries/study/ConceptionsByDam.sql index 599a1ed..61694a1 100644 --- a/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql +++ b/nbri_ehr/resources/queries/study/ConceptionsByDam.sql @@ -4,11 +4,11 @@ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 */ SELECT - c.Dam AS Id, - c.ConceptId, - c.ConceptDate, - c.Estimated, - c.Sire, + c.Id, + c.conceptId, + c.date, + c.estimated, + c.sire, c.isActive, CASE WHEN c.isActive = true THEN 'Unknown' @@ -16,12 +16,12 @@ SELECT ELSE COALESCE(po.result, 'Unknown') END AS conceptionOutcome, b.offspring, - c.Remark, - c.QCState AS qcstate -FROM Conception c + c.remark, + c.qcstate +FROM conception c -- Both joins match isActive: a record claims its conception unless its QC state is explicitly non-public, so a null state counts as public. -- The birth trigger blocks a duplicate conceptId, but ETL imports skip that check, so the aggregate guards against one. LEFT JOIN (SELECT b.conceptId, MAX(b.Id) AS offspring FROM study.birth b WHERE b.conceptId IS NOT NULL AND (b.qcstate IS NULL OR b.qcstate.publicdata = true) GROUP BY b.conceptId) b - ON b.conceptId = c.ConceptId + ON b.conceptId = c.conceptId LEFT JOIN (SELECT p.conceptId, MAX(p.result.title) AS result FROM study.pregnancy p WHERE p.conceptId IS NOT NULL AND (p.qcstate IS NULL OR p.qcstate.publicdata = true) GROUP BY p.conceptId) po - ON po.conceptId = c.ConceptId + ON po.conceptId = c.conceptId diff --git a/nbri_ehr/resources/queries/study/activeConceptions.sql b/nbri_ehr/resources/queries/study/activeConceptions.sql index 3fd6f1f..d124ca6 100644 --- a/nbri_ehr/resources/queries/study/activeConceptions.sql +++ b/nbri_ehr/resources/queries/study/activeConceptions.sql @@ -4,8 +4,8 @@ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 */ SELECT - c.Dam AS Id, - c.ConceptId, - c.ConceptDate -FROM nbri_ehr.Conception c -WHERE c.isActive = true AND c.Dam IS NOT NULL + c.Id, + c.conceptId, + c.date +FROM study.conception c +WHERE c.isActive = true AND c.Id IS NOT NULL diff --git a/nbri_ehr/resources/queries/study/birth.js b/nbri_ehr/resources/queries/study/birth.js index fa4f20a..2174306 100644 --- a/nbri_ehr/resources/queries/study/birth.js +++ b/nbri_ehr/resources/queries/study/birth.js @@ -87,7 +87,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } if (damsToSync.length) { - triggerHelper.reportDataChange('nbri_ehr', 'Conception', damsToSync); + triggerHelper.reportDataChange('study', 'conception', damsToSync); damsToSync = []; } }); @@ -104,7 +104,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } if (!helper.isETL() && row.conceptId) { - if (triggerHelper.totalRecords('nbri_ehr', 'Conception', 'ConceptId', row.conceptId) === 0) { + if (triggerHelper.totalRecords('study', 'conception', 'conceptId', row.conceptId) === 0) { EHR.Server.Utils.addError(scriptErrors, 'conceptId', 'This conception Id does not match any conception record', 'WARN'); } diff --git a/nbri_ehr/resources/queries/study/birth.query.xml b/nbri_ehr/resources/queries/study/birth.query.xml index fc7174f..329725d 100644 --- a/nbri_ehr/resources/queries/study/birth.query.xml +++ b/nbri_ehr/resources/queries/study/birth.query.xml @@ -16,9 +16,9 @@ Conception Id - nbri_ehr - Conception - ConceptId + study + conception + conceptId diff --git a/nbri_ehr/resources/queries/study/conception.js b/nbri_ehr/resources/queries/study/conception.js new file mode 100644 index 0000000..e3b34d3 --- /dev/null +++ b/nbri_ehr/resources/queries/study/conception.js @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +require("ehr/triggers").initScript(this); + +var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); + +// conception ids claimed by the rows of this save that have already been validated. Rows entered together are not in +// study.conception yet when each one is checked, so this is the only way the one-record-per-conception rule can see them. +var conceptIdsInSave = []; + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.INIT, 'study', 'conception', function(event, helper){ + helper.setScriptOptions({ + // gestation puts the conception date months behind the entry date, so a distant past date is the normal case + allowDatesInDistantPast: true, + allowDeadIds: true, + skipHousingCheck: true, + skipAssignmentCheck: true + }); + + // the script scope can outlive a single save, so never inherit ids from a prior one + conceptIdsInSave = []; +}); + +// conceptId was a unique constraint before this became a dataset, and the birth and pregnancy triggers still resolve a +// conception by that id alone, so the rule is enforced here now +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_UPSERT, 'study', 'conception', function(helper, scriptErrors, row, oldRow) { + if (helper.isETL() || !row.conceptId) + return; + + //when updating a record that already carries this conception id, the existing row accounts for one match + var conceptIdThreshold = (oldRow && oldRow.conceptId === row.conceptId) ? 1 : 0; + var claimedBySavedRow = triggerHelper.totalRecords('study', 'conception', 'conceptId', row.conceptId) > conceptIdThreshold; + + // rows are validated one at a time and collected below, so this list holds the earlier rows of this save only + var claimedByEarlierRow = conceptIdsInSave.indexOf(row.conceptId) > -1; + + if (claimedBySavedRow || claimedByEarlierRow) { + EHR.Server.Utils.addError(scriptErrors, 'conceptId', 'This conception Id is already used by another conception record', 'ERROR'); + } + + conceptIdsInSave.push(row.conceptId); +}); diff --git a/nbri_ehr/resources/queries/study/conception.query.xml b/nbri_ehr/resources/queries/study/conception.query.xml new file mode 100644 index 0000000..b3efa42 --- /dev/null +++ b/nbri_ehr/resources/queries/study/conception.query.xml @@ -0,0 +1,29 @@ + + + + + + Conception Records + + + Dam + + + Conception Date + Date + + + Conception Id + true + + + Estimated + Conception date is estimated rather than observed + + + + +
+
+
+
diff --git a/nbri_ehr/resources/queries/study/pregnancy.js b/nbri_ehr/resources/queries/study/pregnancy.js index c989278..33fbb1d 100644 --- a/nbri_ehr/resources/queries/study/pregnancy.js +++ b/nbri_ehr/resources/queries/study/pregnancy.js @@ -30,7 +30,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_UPSERT, 'study', 'pregnancy', function(helper, scriptErrors, row, oldRow) { if (!helper.isETL() && row.conceptId) { - if (triggerHelper.totalRecords('nbri_ehr', 'Conception', 'ConceptId', row.conceptId) === 0) { + if (triggerHelper.totalRecords('study', 'conception', 'conceptId', row.conceptId) === 0) { EHR.Server.Utils.addError(scriptErrors, 'conceptId', 'This conception Id does not match any conception record', 'WARN'); } @@ -61,7 +61,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.COMPLETE, 'study', 'pregnancy', function(event, errors, helper){ if (damsToSync.length) { - triggerHelper.reportDataChange('nbri_ehr', 'Conception', damsToSync); + triggerHelper.reportDataChange('study', 'conception', damsToSync); damsToSync = []; } }); diff --git a/nbri_ehr/resources/queries/study/pregnancy.query.xml b/nbri_ehr/resources/queries/study/pregnancy.query.xml index 5ee2a9f..6a33f3f 100644 --- a/nbri_ehr/resources/queries/study/pregnancy.query.xml +++ b/nbri_ehr/resources/queries/study/pregnancy.query.xml @@ -24,9 +24,9 @@ Conception Id - nbri_ehr - Conception - ConceptId + study + conception + conceptId diff --git a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_manifest.xml b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_manifest.xml index fbc9052..aef63d0 100644 --- a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_manifest.xml +++ b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_manifest.xml @@ -13,6 +13,7 @@ + diff --git a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml index b036dfa..f0653ae 100644 --- a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml +++ b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml @@ -442,6 +442,42 @@ + + Conception Records + Records a conception, filed against the dam. The outcome is claimed later by a birth or pregnancy outcome record carrying the same conception Id. + + + Dam + varchar + http://cpas.labkey.com/Study#ParticipantId + + ptid + dam + + + + Conception Date + timestamp + http://cpas.labkey.com/Study#VisitDate + http://cpas.labkey.com/Study#VisitDate + + conceptDate + + + + Conception Id + varchar + + + Estimated + boolean + + + Sire + varchar + + +
DemographicsContains core demographic information for each primate, including species, sex, parentage, birth and death dates, and colony status. diff --git a/nbri_ehr/resources/reports/additionalReports.tsv b/nbri_ehr/resources/reports/additionalReports.tsv index efaa2ad..043dd05 100644 --- a/nbri_ehr/resources/reports/additionalReports.tsv +++ b/nbri_ehr/resources/reports/additionalReports.tsv @@ -49,4 +49,4 @@ behaviorRemarks Behavior query Behavior Remarks true study BehaviorClinRemarks clinObsBehavior Behavior query Observations true study behaviorObservations date false false qcstate/publicdata This report contains one record for each encounter with each animal, including surergies, exams, procedures, etc. clinremarks Clinical query Clinical Remarks true study ClinicalClinRemarks date false false qcstate/publicdata This report contains the clinical remarks entered about each animal physicalExam Clinical query Exam History True study physicalExam date false false qcstate/publicdata This report displays physical exam data for the selected animal -conceptionsByDam Reproductive Management query Conceptions by Dam true nbri_ehr ConceptionsByDam ConceptDate false false qcstate/publicdata This report displays conception records where the selected animal is the dam \ No newline at end of file +conceptionsByDam Reproductive Management query Conceptions by Dam true study ConceptionsByDam date false false qcstate/publicdata This report displays conception records where the selected animal is the dam \ No newline at end of file diff --git a/nbri_ehr/resources/schemas/dbscripts/postgresql/nbri_ehr-26.002-26.003.sql b/nbri_ehr/resources/schemas/dbscripts/postgresql/nbri_ehr-26.002-26.003.sql new file mode 100644 index 0000000..852e5fb --- /dev/null +++ b/nbri_ehr/resources/schemas/dbscripts/postgresql/nbri_ehr-26.002-26.003.sql @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +-- Conception is now the study.conception dataset, filed against the dam on the conception date. Existing rows are not carried over. +DROP TABLE IF EXISTS nbri_ehr.Conception; + diff --git a/nbri_ehr/resources/schemas/nbri_ehr.xml b/nbri_ehr/resources/schemas/nbri_ehr.xml index 9c11a59..a58688d 100644 --- a/nbri_ehr/resources/schemas/nbri_ehr.xml +++ b/nbri_ehr/resources/schemas/nbri_ehr.xml @@ -1,28 +1,4 @@ - -
- Conception - DETAILED - - - - - Date - - - - - - - - - - - - - -
- diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Conception.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Conception.js index 076ce02..7152e26 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Conception.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Conception.js @@ -8,59 +8,54 @@ EHR.model.DataModelManager.registerMetadata('Conception', { }, byQuery: { - 'nbri_ehr.Conception': { - RowId: { - allowBlank: true, - nullable: true, - hidden: true - }, - ConceptId: { + 'study.conception': { + Id: { + label: 'Dam', allowBlank: false, nullable: false, columnConfig: { width: 200 - }, + } }, - ConceptDate: { + date: { + label: 'Conception Date', xtype: 'datefield', extFormat: LABKEY.extDefaultDateFormat, allowBlank: false, nullable: false, columnConfig: { width: 200 - }, - }, - Estimated: { - xtype: 'checkbox', - defaultValue: false, - columnConfig: { - width: 100 - }, + } }, - Dam: { - xtype: 'ehr-animalfield', - lookups: false, + conceptId: { allowBlank: false, nullable: false, columnConfig: { width: 200 - }, + } }, - Sire: { + estimated: { + xtype: 'checkbox', + defaultValue: false, + columnConfig: { + width: 100 + } + }, + sire: { xtype: 'ehr-animalfield', lookups: false, columnConfig: { width: 200 - }, + } }, - Remark: { + remark: { height: 75, editorConfig: { resizeDirections: 's' }, columnConfig: { width: 300 - }, + } } }, diff --git a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js index 9bcccb7..536d53e 100644 --- a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js +++ b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js @@ -405,12 +405,12 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { if (Ext4.isArray(records)){ Ext4.each(records, function(record){ - var conceptId = record['ConceptId']; + var conceptId = record['conceptId']; if (conceptId){ var url = LABKEY.ActionURL.buildURL('query', 'executeQuery', ctx['EHRStudyContainer'], { - schemaName: 'nbri_ehr', - 'query.queryName': 'Conception', - 'query.ConceptId~eq': conceptId + schemaName: 'study', + 'query.queryName': 'conception', + 'query.conceptId~eq': conceptId }); values.push('' + LABKEY.Utils.encodeHtml(conceptId) + ''); } diff --git a/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js b/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js index 3557aa7..0030c33 100644 --- a/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js +++ b/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js @@ -41,18 +41,18 @@ Ext4.define('NBRI_EHR.window.StartWithConceptionWindow', { itemId: 'conceptionField', fieldLabel: 'Conception Id', value: isExistingRow ? this.targetRecord.get('conceptId') : null, - displayField: 'ConceptId', - valueField: 'ConceptId', + displayField: 'conceptId', + valueField: 'conceptId', forceSelection: true, queryMode: 'local', anyMatch: true, caseSensitive: false, store: { type: 'labkey-store', - schemaName: 'nbri_ehr', - queryName: 'Conception', - columns: 'ConceptId,ConceptDate,Dam,Sire', - sort: '-ConceptDate', + schemaName: 'study', + queryName: 'conception', + columns: 'conceptId,date,Id,sire', + sort: '-date', autoLoad: true } }], @@ -85,8 +85,8 @@ Ext4.define('NBRI_EHR.window.StartWithConceptionWindow', { return; } - var dam = record.get('Dam'); - var sire = record.get('Sire'); + var dam = record.get('Id'); + var sire = record.get('sire'); btn.disable(); this.getDamAttributes(dam, function(damAttributes, speciesError){ diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIConceptionFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIConceptionFormSection.java index e8ed5d8..ee1a604 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIConceptionFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIConceptionFormSection.java @@ -19,7 +19,7 @@ public class NBRIConceptionFormSection extends BaseFormSection { public NBRIConceptionFormSection(String label) { - super("nbri_ehr", "conception", label, "ehr-gridpanel", true, false, false); + super("study", "conception", label, "ehr-gridpanel", true, false, false); // a conception is not recorded against a batch of animals, so drop the Add Batch button setAllowBulkAdd(false); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java index b27b347..7f2338b 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java @@ -36,8 +36,8 @@ public ActiveConceptionsDemographicsProvider(Module module) @Override public boolean requiresRecalc(String schema, String query) { - return ("study".equalsIgnoreCase(schema) && ("birth".equalsIgnoreCase(query) || "pregnancy".equalsIgnoreCase(query))) || - ("nbri_ehr".equalsIgnoreCase(schema) && "Conception".equalsIgnoreCase(query)); + return "study".equalsIgnoreCase(schema) && + ("birth".equalsIgnoreCase(query) || "pregnancy".equalsIgnoreCase(query) || "conception".equalsIgnoreCase(query)); } @Override @@ -45,8 +45,8 @@ protected Collection getFieldKeys() { Set keys = new HashSet<>(); keys.add(FieldKey.fromString("Id")); - keys.add(FieldKey.fromString("ConceptId")); - keys.add(FieldKey.fromString("ConceptDate")); + keys.add(FieldKey.fromString("conceptId")); + keys.add(FieldKey.fromString("date")); return keys; } @@ -54,6 +54,6 @@ protected Collection getFieldKeys() @Override protected Sort getSort() { - return new Sort("-ConceptDate"); + return new Sort("-date"); } } diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java index 30724df..f02e712 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java @@ -841,16 +841,17 @@ public long totalRecords(String schemaName, String queryName, String columnName, return ts.getRowCount(); } - // The Conception table has no Id column, so its trigger cannot announce a modified participant on its own + // A birth or pregnancy outcome row announces its own animal, which is the offspring rather than the dam, so the + // dam has to be read back off the conception the row claims public String getConceptionDam(String conceptId) { if (conceptId == null) return null; - TableInfo ti = getTableInfo("nbri_ehr", "Conception"); - SimpleFilter filter = new SimpleFilter(FieldKey.fromString("ConceptId"), conceptId); + TableInfo ti = getTableInfo("study", "conception"); + SimpleFilter filter = new SimpleFilter(FieldKey.fromString("conceptId"), conceptId); - return new TableSelector(ti, Collections.singleton("Dam"), filter, null).getObject(String.class); + return new TableSelector(ti, Collections.singleton("Id"), filter, null).getObject(String.class); } public boolean canCloseCase() diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java b/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java index ca8a01a..c9ae774 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java @@ -646,7 +646,7 @@ public void doTableSpecificCustomizations(AbstractTableInfo ti) { addIsActiveForProject(ti, EHRService.EndingOption.activeAfterMidnightTonight); } - if (matches(ti, "nbri_ehr", "Conception")) + if (matches(ti, "study", "conception")) { addIsActiveForConception(ti); } diff --git a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java index 9861c69..11af04e 100644 --- a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java +++ b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java @@ -793,8 +793,8 @@ public void testBirthForm() throws Exception createBreedingPair(damId, sireId, damSpeciesCode, damGeneration); log("Creating conception record"); - InsertRowsCommand conception = new InsertRowsCommand("nbri_ehr", "Conception"); - conception.addRow(Map.of("ConceptId", conceptId, "ConceptDate", now.minusDays(160), "Dam", damId, "Sire", sireId)); + InsertRowsCommand conception = new InsertRowsCommand("study", "conception"); + conception.addRow(Map.of("conceptId", conceptId, "date", now.minusDays(160), "Id", damId, "sire", sireId, "QCStateLabel", "Completed")); conception.execute(getApiHelper().getConnection(), getContainerPath()); log("Verifying the dam's Animal Details reports the open conception before the birth"); @@ -911,8 +911,8 @@ public void testBirthForm() throws Exception log("Verifying conception outcome and offspring in ConceptionsByDam"); goToSchemaBrowser(); - DataRegionTable report = viewQueryData("nbri_ehr", "ConceptionsByDam"); - report.setFilter("ConceptId", "Equals", conceptId); + DataRegionTable report = viewQueryData("study", "ConceptionsByDam"); + report.setFilter("conceptId", "Equals", conceptId); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(damId), report.getRowDataAsText(0, "Id")); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList("Live Birth"), report.getRowDataAsText(0, "conceptionOutcome")); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(bornAnimal), report.getRowDataAsText(0, "offspring")); @@ -944,9 +944,9 @@ public void testDuplicateConceptionRejected() throws Exception createBreedingPair(damId, sireId, damSpeciesCode); log("Creating two conception records for that pair"); - InsertRowsCommand conceptions = new InsertRowsCommand("nbri_ehr", "Conception"); - conceptions.addRow(Map.of("ConceptId", firstConcept, "ConceptDate", now.minusDays(200), "Dam", damId, "Sire", sireId)); - conceptions.addRow(Map.of("ConceptId", secondConcept, "ConceptDate", now.minusDays(160), "Dam", damId, "Sire", sireId)); + InsertRowsCommand conceptions = new InsertRowsCommand("study", "conception"); + conceptions.addRow(Map.of("conceptId", firstConcept, "date", now.minusDays(200), "Id", damId, "sire", sireId, "QCStateLabel", "Completed")); + conceptions.addRow(Map.of("conceptId", secondConcept, "date", now.minusDays(160), "Id", damId, "sire", sireId, "QCStateLabel", "Completed")); conceptions.execute(getApiHelper().getConnection(), getContainerPath()); gotoEnterData(); @@ -1017,9 +1017,9 @@ public void testConceptionPickedFromGridCell() throws Exception createBreedingPair(firstDam, firstSire, firstSpeciesCode, firstDamGeneration); createBreedingPair(secondDam, secondSire, secondSpeciesCode, secondDamGeneration); - InsertRowsCommand conceptions = new InsertRowsCommand("nbri_ehr", "Conception"); - conceptions.addRow(Map.of("ConceptId", firstConcept, "ConceptDate", now.minusDays(200), "Dam", firstDam, "Sire", firstSire)); - conceptions.addRow(Map.of("ConceptId", secondConcept, "ConceptDate", now.minusDays(190), "Dam", secondDam, "Sire", secondSire)); + InsertRowsCommand conceptions = new InsertRowsCommand("study", "conception"); + conceptions.addRow(Map.of("conceptId", firstConcept, "date", now.minusDays(200), "Id", firstDam, "sire", firstSire, "QCStateLabel", "Completed")); + conceptions.addRow(Map.of("conceptId", secondConcept, "date", now.minusDays(190), "Id", secondDam, "sire", secondSire, "QCStateLabel", "Completed")); conceptions.execute(getApiHelper().getConnection(), getContainerPath()); gotoEnterData(); @@ -1101,8 +1101,8 @@ public void testPregnancyForm() throws IOException, CommandException LocalDateTime now = LocalDateTime.now(); log("Creating conception record"); - InsertRowsCommand conception = new InsertRowsCommand("nbri_ehr", "Conception"); - conception.addRow(Map.of("ConceptId", conceptId, "ConceptDate", now.minusDays(90), "Dam", animalId)); + InsertRowsCommand conception = new InsertRowsCommand("study", "conception"); + conception.addRow(Map.of("conceptId", conceptId, "date", now.minusDays(90), "Id", animalId, "QCStateLabel", "Completed")); conception.execute(getApiHelper().getConnection(), getContainerPath()); gotoEnterData(); @@ -1128,8 +1128,8 @@ public void testPregnancyForm() throws IOException, CommandException log("Verifying conception outcome in ConceptionsByDam"); goToSchemaBrowser(); - DataRegionTable report = viewQueryData("nbri_ehr", "ConceptionsByDam"); - report.setFilter("ConceptId", "Equals", conceptId); + DataRegionTable report = viewQueryData("study", "ConceptionsByDam"); + report.setFilter("conceptId", "Equals", conceptId); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(animalId), report.getRowDataAsText(0, "Id")); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(result), report.getRowDataAsText(0, "conceptionOutcome")); Assert.assertEquals("A conception claimed by a pregnancy outcome should not be active", @@ -1158,29 +1158,29 @@ public void testConceptionForm() conceptions.isColumnPresent("breedingType", false)); _helper.addRecordToGrid(conceptions); - conceptions.setGridCell(1, "ConceptId", conceptId); - conceptions.setGridCellJS(1, "ConceptDate", now.minusDays(30).format(_dateFormat)); - conceptions.setGridCellJS(1, "Estimated", true); - conceptions.setGridCell(1, "Dam", damId); - conceptions.setGridCell(1, "Sire", sireId); + conceptions.setGridCell(1, "conceptId", conceptId); + conceptions.setGridCellJS(1, "date", now.minusDays(30).format(_dateFormat)); + conceptions.setGridCellJS(1, "estimated", true); + conceptions.setGridCell(1, "Id", damId); + conceptions.setGridCell(1, "sire", sireId); // Remark renders as a textarea, which Ext4GridRef's cell editor helpers cannot drive: they only recognize // an as the active editor, so the click that opens the textarea is followed by a retry click that // the open textarea intercepts. Set it through the store instead. - conceptions.setGridCellJS(1, "Remark", "Conception entry test"); + conceptions.setGridCellJS(1, "remark", "Conception entry test"); submitForm("Submit Final", "Finalize"); goToSchemaBrowser(); - DataRegionTable table = viewQueryData("nbri_ehr", "Conception"); - table.setFilter("ConceptId", "Equals", conceptId); - Assert.assertEquals("Invalid Conception record", Arrays.asList(damId), table.getRowDataAsText(0, "Dam")); - Assert.assertEquals("Invalid Conception record", Arrays.asList(sireId), table.getRowDataAsText(0, "Sire")); - Assert.assertEquals("Invalid Conception record", Arrays.asList("true"), table.getRowDataAsText(0, "Estimated")); - Assert.assertEquals("Invalid Conception record", Arrays.asList("Conception entry test"), table.getRowDataAsText(0, "Remark")); + DataRegionTable table = viewQueryData("study", "conception"); + table.setFilter("conceptId", "Equals", conceptId); + Assert.assertEquals("Invalid Conception record", Arrays.asList(damId), table.getRowDataAsText(0, "Id")); + Assert.assertEquals("Invalid Conception record", Arrays.asList(sireId), table.getRowDataAsText(0, "sire")); + Assert.assertEquals("Invalid Conception record", Arrays.asList("true"), table.getRowDataAsText(0, "estimated")); + Assert.assertEquals("Invalid Conception record", Arrays.asList("Conception entry test"), table.getRowDataAsText(0, "remark")); log("Verifying unmatched conception appears as Unknown in ConceptionsByDam"); goToSchemaBrowser(); - DataRegionTable report = viewQueryData("nbri_ehr", "ConceptionsByDam"); - report.setFilter("ConceptId", "Equals", conceptId); + DataRegionTable report = viewQueryData("study", "ConceptionsByDam"); + report.setFilter("conceptId", "Equals", conceptId); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(damId), report.getRowDataAsText(0, "Id")); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList("Unknown"), report.getRowDataAsText(0, "conceptionOutcome")); Assert.assertEquals("A conception with no birth or pregnancy outcome should be active", From a883bc8ad59dbea5c5f07fecd1e8a07eaf77a541 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 11 Sep 2026 13:40:01 -0600 Subject: [PATCH 2/8] Add a conceptionDays column to the conception dataset Calendar days are differenced rather than instants, so a conception entered today reads zero. --- .../nbri_ehr/table/NBRI_EHRCustomizer.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java b/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java index c9ae774..fa37b8f 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java @@ -28,6 +28,7 @@ import org.labkey.api.data.MutableColumnInfo; import org.labkey.api.data.RenderContext; import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.dialect.SqlDialect; import org.labkey.api.data.TableInfo; import org.labkey.api.data.WrappedColumn; import org.labkey.api.ehr.EHRService; @@ -649,6 +650,7 @@ public void doTableSpecificCustomizations(AbstractTableInfo ti) if (matches(ti, "study", "conception")) { addIsActiveForConception(ti); + addConceptionDaysCol(ti); } if (matches(ti, "study", "protocolAssignment")) { @@ -733,6 +735,31 @@ private void addIsActiveForConception(AbstractTableInfo ti) ti.setDefaultVisibleColumns(visible); } + private void addConceptionDaysCol(AbstractTableInfo ti) + { + String name = "conceptionDays"; + if (ti.getColumn(name) != null || ti.getColumn("date") == null) + return; + + // The stored date carries a time, so truncate it: differencing it against midnight today otherwise leaves a + // conception entered today a fraction of a day in the future, which the dialect's rounding cast turns into -1. + // Cast back to a timestamp because date minus date is an integer in postgres, which EXTRACT(EPOCH) rejects. + SqlDialect dialect = ti.getSqlDialect(); + String conceptionDay = "CAST(" + dialect.getDateTimeToDateCast(ExprColumn.STR_TABLE_ALIAS + ".date") + " AS " + dialect.getDefaultDateTimeDataType() + ")"; + SQLFragment sql = new SQLFragment(dialect.getDateDiff(Calendar.DATE, "{fn curdate()}", conceptionDay)); + ExprColumn col = new ExprColumn(ti, name, sql, JdbcType.INTEGER, ti.getColumn("date")); + col.setLabel("Conception Days"); + col.setDescription("Days elapsed from the conception date to today."); + ti.addColumn(col); + + // Customizers run after the query XML column reorder, so listing conceptionDays there does nothing and it lands last + List visible = new ArrayList<>(ti.getDefaultVisibleColumns()); + visible.remove(col.getFieldKey()); + int dateIndex = visible.indexOf(FieldKey.fromParts("date")); + visible.add(dateIndex < 0 ? visible.size() : dateIndex + 1, col.getFieldKey()); + ti.setDefaultVisibleColumns(visible); + } + // A null QCState means none was assigned, which LabKey treats as visible, so only an explicitly non-public state hides a row private String isPublicSql(String tableAlias, String isFalse) { From 8239d9467a50296474ff55dfdeb9b64415de3824 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 11 Sep 2026 13:59:27 -0600 Subject: [PATCH 3/8] Show the conception day count on the animal snapshot and the dam report The snapshot's Pregnant field reads the count from the cached demographics record, so it can trail the report by a day until the entry expires. --- nbri_ehr/resources/queries/study/ConceptionsByDam.sql | 1 + nbri_ehr/resources/queries/study/activeConceptions.sql | 3 ++- nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js | 10 +++++++++- .../ActiveConceptionsDemographicsProvider.java | 1 + .../org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java | 8 +++++--- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/nbri_ehr/resources/queries/study/ConceptionsByDam.sql b/nbri_ehr/resources/queries/study/ConceptionsByDam.sql index 61694a1..cf371fd 100644 --- a/nbri_ehr/resources/queries/study/ConceptionsByDam.sql +++ b/nbri_ehr/resources/queries/study/ConceptionsByDam.sql @@ -7,6 +7,7 @@ SELECT c.Id, c.conceptId, c.date, + c.conceptionDays, c.estimated, c.sire, c.isActive, diff --git a/nbri_ehr/resources/queries/study/activeConceptions.sql b/nbri_ehr/resources/queries/study/activeConceptions.sql index d124ca6..19008db 100644 --- a/nbri_ehr/resources/queries/study/activeConceptions.sql +++ b/nbri_ehr/resources/queries/study/activeConceptions.sql @@ -6,6 +6,7 @@ SELECT c.Id, c.conceptId, - c.date + c.date, + c.conceptionDays FROM study.conception c WHERE c.isActive = true AND c.Id IS NOT NULL diff --git a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js index 536d53e..ddeeffa 100644 --- a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js +++ b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js @@ -412,7 +412,15 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { 'query.queryName': 'conception', 'query.conceptId~eq': conceptId }); - values.push('' + LABKEY.Utils.encodeHtml(conceptId) + ''); + var link = '' + LABKEY.Utils.encodeHtml(conceptId) + ''; + + // a record cached before conceptionDays was added to the provider carries no count, so show the id alone + var days = record['conceptionDays']; + if (days !== null && days !== undefined){ + link += ' (' + LABKEY.Utils.encodeHtml(String(days)) + (Number(days) === 1 ? ' day' : ' days') + ')'; + } + + values.push(link); } }, this); } diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java index 7f2338b..9803ce5 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java @@ -47,6 +47,7 @@ protected Collection getFieldKeys() keys.add(FieldKey.fromString("Id")); keys.add(FieldKey.fromString("conceptId")); keys.add(FieldKey.fromString("date")); + keys.add(FieldKey.fromString("conceptionDays")); return keys; } diff --git a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java index 11af04e..6cfcc87 100644 --- a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java +++ b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java @@ -794,11 +794,13 @@ public void testBirthForm() throws Exception log("Creating conception record"); InsertRowsCommand conception = new InsertRowsCommand("study", "conception"); - conception.addRow(Map.of("conceptId", conceptId, "date", now.minusDays(160), "Id", damId, "sire", sireId, "QCStateLabel", "Completed")); + int conceptionDays = 160; + conception.addRow(Map.of("conceptId", conceptId, "date", now.minusDays(conceptionDays), "Id", damId, "sire", sireId, "QCStateLabel", "Completed")); conception.execute(getApiHelper().getConnection(), getContainerPath()); - log("Verifying the dam's Animal Details reports the open conception before the birth"); - assertEquals("Animal Details did not report the open conception", conceptId, getSnapshotFieldValue(damId, "Pregnant")); + log("Verifying the dam's Animal Details reports the open conception and its day count before the birth"); + assertEquals("Animal Details did not report the open conception", conceptId + " (" + conceptionDays + " days)", + getSnapshotFieldValue(damId, "Pregnant")); gotoEnterData(); waitAndClickAndWait(Locator.linkWithText("Birth")); From c64102ba7f0ac215a5e01ce8f2c48ba3dd6e678d Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sat, 12 Sep 2026 05:26:32 -0600 Subject: [PATCH 4/8] Bump the schema version and set performedby on conception test inserts The 26.002-26.003 script never ran: the module still declared schema version 26.002, so nbri_ehr.Conception survived and no longer matched nbri_ehr.xml. The shared dataset trigger rejects a Completed record with no performedby, which the conception dataset is now subject to, so the test's API inserts have to supply one. --- nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java | 2 +- .../org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java b/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java index 06c078e..39b5f96 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java @@ -81,7 +81,7 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 26.002; + return 26.003; } @Override diff --git a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java index 6cfcc87..7d0ba76 100644 --- a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java +++ b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java @@ -795,7 +795,7 @@ public void testBirthForm() throws Exception log("Creating conception record"); InsertRowsCommand conception = new InsertRowsCommand("study", "conception"); int conceptionDays = 160; - conception.addRow(Map.of("conceptId", conceptId, "date", now.minusDays(conceptionDays), "Id", damId, "sire", sireId, "QCStateLabel", "Completed")); + conception.addRow(Map.of("conceptId", conceptId, "date", now.minusDays(conceptionDays), "Id", damId, "sire", sireId, "QCStateLabel", "Completed", "performedby", 1004)); conception.execute(getApiHelper().getConnection(), getContainerPath()); log("Verifying the dam's Animal Details reports the open conception and its day count before the birth"); @@ -947,8 +947,8 @@ public void testDuplicateConceptionRejected() throws Exception log("Creating two conception records for that pair"); InsertRowsCommand conceptions = new InsertRowsCommand("study", "conception"); - conceptions.addRow(Map.of("conceptId", firstConcept, "date", now.minusDays(200), "Id", damId, "sire", sireId, "QCStateLabel", "Completed")); - conceptions.addRow(Map.of("conceptId", secondConcept, "date", now.minusDays(160), "Id", damId, "sire", sireId, "QCStateLabel", "Completed")); + conceptions.addRow(Map.of("conceptId", firstConcept, "date", now.minusDays(200), "Id", damId, "sire", sireId, "QCStateLabel", "Completed", "performedby", 1004)); + conceptions.addRow(Map.of("conceptId", secondConcept, "date", now.minusDays(160), "Id", damId, "sire", sireId, "QCStateLabel", "Completed", "performedby", 1004)); conceptions.execute(getApiHelper().getConnection(), getContainerPath()); gotoEnterData(); @@ -1020,8 +1020,8 @@ public void testConceptionPickedFromGridCell() throws Exception createBreedingPair(secondDam, secondSire, secondSpeciesCode, secondDamGeneration); InsertRowsCommand conceptions = new InsertRowsCommand("study", "conception"); - conceptions.addRow(Map.of("conceptId", firstConcept, "date", now.minusDays(200), "Id", firstDam, "sire", firstSire, "QCStateLabel", "Completed")); - conceptions.addRow(Map.of("conceptId", secondConcept, "date", now.minusDays(190), "Id", secondDam, "sire", secondSire, "QCStateLabel", "Completed")); + conceptions.addRow(Map.of("conceptId", firstConcept, "date", now.minusDays(200), "Id", firstDam, "sire", firstSire, "QCStateLabel", "Completed", "performedby", 1004)); + conceptions.addRow(Map.of("conceptId", secondConcept, "date", now.minusDays(190), "Id", secondDam, "sire", secondSire, "QCStateLabel", "Completed", "performedby", 1004)); conceptions.execute(getApiHelper().getConnection(), getContainerPath()); gotoEnterData(); @@ -1104,7 +1104,7 @@ public void testPregnancyForm() throws IOException, CommandException log("Creating conception record"); InsertRowsCommand conception = new InsertRowsCommand("study", "conception"); - conception.addRow(Map.of("conceptId", conceptId, "date", now.minusDays(90), "Id", animalId, "QCStateLabel", "Completed")); + conception.addRow(Map.of("conceptId", conceptId, "date", now.minusDays(90), "Id", animalId, "QCStateLabel", "Completed", "performedby", 1004)); conception.execute(getApiHelper().getConnection(), getContainerPath()); gotoEnterData(); From cda530f3ec6a9de23fc1e5a6e28a769682a3aa3d Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sat, 12 Sep 2026 09:27:06 -0600 Subject: [PATCH 5/8] Display the conception Id, not the dam, in conception lookups The birth and pregnancy conception lookups had no display column, so the dataset's title column decided what they rendered, and with none set that falls back to the first string column: the dam. --- nbri_ehr/resources/queries/study/conception.query.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nbri_ehr/resources/queries/study/conception.query.xml b/nbri_ehr/resources/queries/study/conception.query.xml index b3efa42..374d983 100644 --- a/nbri_ehr/resources/queries/study/conception.query.xml +++ b/nbri_ehr/resources/queries/study/conception.query.xml @@ -4,6 +4,8 @@ Conception Records + + conceptId Dam From a77f9ea249959bbef1f29b7859ecc3b758322cb3 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sat, 12 Sep 2026 09:47:36 -0600 Subject: [PATCH 6/8] Link conception lookups to the conception record EHR assigns a details URL only to demographics, so the conception dataset had none and every lookup into it rendered as plain text instead of a link. --- nbri_ehr/resources/queries/study/conception.query.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/nbri_ehr/resources/queries/study/conception.query.xml b/nbri_ehr/resources/queries/study/conception.query.xml index 374d983..6a9f555 100644 --- a/nbri_ehr/resources/queries/study/conception.query.xml +++ b/nbri_ehr/resources/queries/study/conception.query.xml @@ -4,6 +4,7 @@
Conception Records + /query/recordDetails.view?schemaName=study&query.queryName=conception&keyField=conceptId&key=${conceptId} conceptId From 4510b7a0459950ae5839828666e81a7356a35ff8 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sat, 12 Sep 2026 09:50:54 -0600 Subject: [PATCH 7/8] Drop the redundant table customizer from the conception dataset The module registers NBRI_EHRCustomizer for all schemas and tables, so a dataset already gets it and declaring it here only ran it a second time. --- nbri_ehr/resources/queries/study/conception.query.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/nbri_ehr/resources/queries/study/conception.query.xml b/nbri_ehr/resources/queries/study/conception.query.xml index 6a9f555..fab299b 100644 --- a/nbri_ehr/resources/queries/study/conception.query.xml +++ b/nbri_ehr/resources/queries/study/conception.query.xml @@ -2,7 +2,6 @@
- Conception Records /query/recordDetails.view?schemaName=study&query.queryName=conception&keyField=conceptId&key=${conceptId} From b922f9e8f8c5440951e5cc11a85705604195b984 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sat, 12 Sep 2026 15:54:15 -0600 Subject: [PATCH 8/8] Apply conception Id uniqueness to ETL and bound the elapsed day count Exempting ETL from the conceptId check left duplicates that make getConceptionDam() throw on a later birth or pregnancy save, now that no unique constraint backs it. conceptionDays goes null once a birth or pregnancy outcome claims the conception, and the animal snapshot counts the days at render time rather than reading a figure the demographics cache can hold for 25 hours. --- .../queries/study/activeConceptions.sql | 3 +- .../resources/queries/study/conception.js | 6 +- .../web/nbri_ehr/panel/SnapshotPanel.js | 24 +++++-- ...ActiveConceptionsDemographicsProvider.java | 1 - .../nbri_ehr/table/NBRI_EHRCustomizer.java | 69 ++++++++++++------- 5 files changed, 70 insertions(+), 33 deletions(-) diff --git a/nbri_ehr/resources/queries/study/activeConceptions.sql b/nbri_ehr/resources/queries/study/activeConceptions.sql index 19008db..d124ca6 100644 --- a/nbri_ehr/resources/queries/study/activeConceptions.sql +++ b/nbri_ehr/resources/queries/study/activeConceptions.sql @@ -6,7 +6,6 @@ SELECT c.Id, c.conceptId, - c.date, - c.conceptionDays + c.date FROM study.conception c WHERE c.isActive = true AND c.Id IS NOT NULL diff --git a/nbri_ehr/resources/queries/study/conception.js b/nbri_ehr/resources/queries/study/conception.js index e3b34d3..64a0504 100644 --- a/nbri_ehr/resources/queries/study/conception.js +++ b/nbri_ehr/resources/queries/study/conception.js @@ -25,9 +25,11 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even }); // conceptId was a unique constraint before this became a dataset, and the birth and pregnancy triggers still resolve a -// conception by that id alone, so the rule is enforced here now +// conception by that id alone, so the rule is enforced here now. Unlike the sibling checks in birth.js and +// pregnancy.js this one does not exempt ETL: getConceptionDam() reads the dam with getObject(), which throws on a +// second match, so a duplicate from any write path breaks later birth and pregnancy saves. EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_UPSERT, 'study', 'conception', function(helper, scriptErrors, row, oldRow) { - if (helper.isETL() || !row.conceptId) + if (!row.conceptId) return; //when updating a record that already carries this conception id, the existing row accounts for one match diff --git a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js index ddeeffa..0f8b048 100644 --- a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js +++ b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js @@ -414,10 +414,11 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { }); var link = '' + LABKEY.Utils.encodeHtml(conceptId) + ''; - // a record cached before conceptionDays was added to the provider carries no count, so show the id alone - var days = record['conceptionDays']; - if (days !== null && days !== undefined){ - link += ' (' + LABKEY.Utils.encodeHtml(String(days)) + (Number(days) === 1 ? ' day' : ' days') + ')'; + // Counted here rather than read off the record because the demographics cache holds a record for up + // to 25 hours, which would leave a cached count a day behind the same figure on the dam report + var days = this.daysSinceConception(record['date']); + if (days !== null){ + link += ' (' + days + (days === 1 ? ' day' : ' days') + ')'; } values.push(link); @@ -427,4 +428,19 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { toSet['pregnant'] = values.length ? values.join('
') : 'No'; }, + + // Whole days from the conception date to today, both truncated to local midnight so the count ticks over at the + // same moment the server's does. Rounded, because a DST boundary leaves the difference an hour short of a multiple. + daysSinceConception: function(value){ + var date = value ? LDK.ConvertUtils.parseDate(value) : null; + if (!Ext4.isDate(date)){ + return null; + } + + var conceptionDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + var now = new Date(); + var today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + return Math.round((today.getTime() - conceptionDay.getTime()) / 86400000); + } }); \ No newline at end of file diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java index 9803ce5..7f2338b 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java @@ -47,7 +47,6 @@ protected Collection getFieldKeys() keys.add(FieldKey.fromString("Id")); keys.add(FieldKey.fromString("conceptId")); keys.add(FieldKey.fromString("date")); - keys.add(FieldKey.fromString("conceptionDays")); return keys; } diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java b/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java index fa37b8f..9c849cd 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java @@ -17,6 +17,7 @@ import io.micrometer.common.util.StringUtils; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.labkey.api.data.AbstractTableInfo; import org.labkey.api.data.ColumnInfo; import org.labkey.api.data.Container; @@ -696,31 +697,15 @@ private void addIsActiveForProject(AbstractTableInfo ti, EHRService.EndingOption private void addIsActiveForConception(AbstractTableInfo ti) { String name = "isActive"; - // both columns back the expression below, so neither may be missing - if (ti.getColumn(name) != null || ti.getColumn("conceptid") == null || ti.getColumn("qcstate") == null) - return; - - UserSchema us = ti.getUserSchema(); - Container ehrContainer = us == null ? null : EHRService.get().getEHRStudyContainer(us.getContainer()); - if (ehrContainer == null) + if (ti.getColumn(name) != null) return; - String birthTable = getDatasetStorageTableName(ehrContainer, "birth"); - String pregnancyTable = getDatasetStorageTableName(ehrContainer, "pregnancy"); - if (birthTable == null || pregnancyTable == null) + String isOpen = openConceptionSql(ti); + if (isOpen == null) return; - String alias = ExprColumn.STR_TABLE_ALIAS; - String isFalse = ti.getSqlDialect().getBooleanFALSE(); - - // ConceptId is globally unique, so the subqueries need no container filter - SQLFragment sql = new SQLFragment("(CASE WHEN (" + - isPublicSql(alias, isFalse) + - " AND NOT EXISTS (SELECT 1 FROM studydataset." + birthTable + " b WHERE b.conceptid = " + alias + ".conceptid AND " + isPublicSql("b", isFalse) + ")" + - " AND NOT EXISTS (SELECT 1 FROM studydataset." + pregnancyTable + " p WHERE p.conceptid = " + alias + ".conceptid AND " + isPublicSql("p", isFalse) + ")" + - ") THEN " + ti.getSqlDialect().getBooleanTRUE() + - " ELSE " + isFalse + - " END)"); + SqlDialect dialect = ti.getSqlDialect(); + SQLFragment sql = new SQLFragment("(CASE WHEN (" + isOpen + ") THEN " + dialect.getBooleanTRUE() + " ELSE " + dialect.getBooleanFALSE() + " END)"); ExprColumn col = new ExprColumn(ti, name, sql, JdbcType.BOOLEAN, ti.getColumn("conceptid"), ti.getColumn("qcstate")); col.setLabel("Is Active?"); @@ -735,21 +720,57 @@ private void addIsActiveForConception(AbstractTableInfo ti) ti.setDefaultVisibleColumns(visible); } + // The condition behind isActive, shared with conceptionDays: this conception is public and no public birth or + // pregnancy outcome record claims its Id. Null when a piece it needs is missing, which drops both calculated + // columns rather than leaving one of them lying about the other. + private @Nullable String openConceptionSql(AbstractTableInfo ti) + { + // both columns back the expression below, so neither may be missing + if (ti.getColumn("conceptid") == null || ti.getColumn("qcstate") == null) + return null; + + UserSchema us = ti.getUserSchema(); + Container ehrContainer = us == null ? null : EHRService.get().getEHRStudyContainer(us.getContainer()); + if (ehrContainer == null) + return null; + + String birthTable = getDatasetStorageTableName(ehrContainer, "birth"); + String pregnancyTable = getDatasetStorageTableName(ehrContainer, "pregnancy"); + if (birthTable == null || pregnancyTable == null) + return null; + + String alias = ExprColumn.STR_TABLE_ALIAS; + String isFalse = ti.getSqlDialect().getBooleanFALSE(); + + // ConceptId is globally unique, so the subqueries need no container filter + return isPublicSql(alias, isFalse) + + " AND NOT EXISTS (SELECT 1 FROM studydataset." + birthTable + " b WHERE b.conceptid = " + alias + ".conceptid AND " + isPublicSql("b", isFalse) + ")" + + " AND NOT EXISTS (SELECT 1 FROM studydataset." + pregnancyTable + " p WHERE p.conceptid = " + alias + ".conceptid AND " + isPublicSql("p", isFalse) + ")"; + } + private void addConceptionDaysCol(AbstractTableInfo ti) { String name = "conceptionDays"; if (ti.getColumn(name) != null || ti.getColumn("date") == null) return; + String isOpen = openConceptionSql(ti); + if (isOpen == null) + return; + // The stored date carries a time, so truncate it: differencing it against midnight today otherwise leaves a // conception entered today a fraction of a day in the future, which the dialect's rounding cast turns into -1. // Cast back to a timestamp because date minus date is an integer in postgres, which EXTRACT(EPOCH) rejects. SqlDialect dialect = ti.getSqlDialect(); String conceptionDay = "CAST(" + dialect.getDateTimeToDateCast(ExprColumn.STR_TABLE_ALIAS + ".date") + " AS " + dialect.getDefaultDateTimeDataType() + ")"; - SQLFragment sql = new SQLFragment(dialect.getDateDiff(Calendar.DATE, "{fn curdate()}", conceptionDay)); - ExprColumn col = new ExprColumn(ti, name, sql, JdbcType.INTEGER, ti.getColumn("date")); + String elapsed = dialect.getDateDiff(Calendar.DATE, "{fn curdate()}", conceptionDay); + + // Null once a birth or pregnancy outcome claims the conception: the count otherwise keeps climbing and reads as + // a gestation age on a record that closed years ago. + SQLFragment sql = new SQLFragment("(CASE WHEN (" + isOpen + ") THEN " + elapsed + " END)"); + ExprColumn col = new ExprColumn(ti, name, sql, JdbcType.INTEGER, ti.getColumn("date"), ti.getColumn("conceptid"), ti.getColumn("qcstate")); col.setLabel("Conception Days"); - col.setDescription("Days elapsed from the conception date to today."); + col.setDescription("Days elapsed from the conception date to today, until a birth or pregnancy outcome claims the conception."); ti.addColumn(col); // Customizers run after the query XML column reorder, so listing conceptionDays there does nothing and it lands last