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..cf371fd 100644 --- a/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql +++ b/nbri_ehr/resources/queries/study/ConceptionsByDam.sql @@ -4,11 +4,12 @@ * 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.conceptionDays, + c.estimated, + c.sire, c.isActive, CASE WHEN c.isActive = true THEN 'Unknown' @@ -16,12 +17,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..64a0504 --- /dev/null +++ b/nbri_ehr/resources/queries/study/conception.js @@ -0,0 +1,47 @@ +/* + * 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. 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 (!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..fab299b --- /dev/null +++ b/nbri_ehr/resources/queries/study/conception.query.xml @@ -0,0 +1,31 @@ + + + + + Conception Records + /query/recordDetails.view?schemaName=study&query.queryName=conception&keyField=conceptId&key=${conceptId} + + conceptId + + + 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..0f8b048 100644 --- a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js +++ b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js @@ -405,18 +405,42 @@ 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) + ''); + var link = '' + LABKEY.Utils.encodeHtml(conceptId) + ''; + + // 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); } }, this); } 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/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/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/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..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; @@ -28,6 +29,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; @@ -646,9 +648,10 @@ public void doTableSpecificCustomizations(AbstractTableInfo ti) { addIsActiveForProject(ti, EHRService.EndingOption.activeAfterMidnightTonight); } - if (matches(ti, "nbri_ehr", "Conception")) + if (matches(ti, "study", "conception")) { addIsActiveForConception(ti); + addConceptionDaysCol(ti); } if (matches(ti, "study", "protocolAssignment")) { @@ -694,42 +697,87 @@ 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) + if (ti.getColumn(name) != null) return; + String isOpen = openConceptionSql(ti); + if (isOpen == null) + return; + + 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?"); + col.setDescription("No birth or pregnancy outcome record has claimed this conception Id."); + ti.addColumn(col); + + // Customizers run after the query XML column reorder, so listing isActive there does nothing and it lands last + List visible = new ArrayList<>(ti.getDefaultVisibleColumns()); + visible.remove(col.getFieldKey()); + int sireIndex = visible.indexOf(FieldKey.fromParts("Sire")); + visible.add(sireIndex < 0 ? visible.size() : sireIndex + 1, col.getFieldKey()); + 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; + return null; String birthTable = getDatasetStorageTableName(ehrContainer, "birth"); String pregnancyTable = getDatasetStorageTableName(ehrContainer, "pregnancy"); if (birthTable == null || pregnancyTable == null) - return; + return null; 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) + + 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) + ")" + - ") THEN " + ti.getSqlDialect().getBooleanTRUE() + - " ELSE " + isFalse + - " END)"); + " AND NOT EXISTS (SELECT 1 FROM studydataset." + pregnancyTable + " p WHERE p.conceptid = " + alias + ".conceptid AND " + isPublicSql("p", isFalse) + ")"; + } - ExprColumn col = new ExprColumn(ti, name, sql, JdbcType.BOOLEAN, ti.getColumn("conceptid"), ti.getColumn("qcstate")); - col.setLabel("Is Active?"); - col.setDescription("No birth or pregnancy outcome record has claimed this conception Id."); + 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() + ")"; + 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, until a birth or pregnancy outcome claims the conception."); ti.addColumn(col); - // Customizers run after the query XML column reorder, so listing isActive there does nothing and it lands last + // 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 sireIndex = visible.indexOf(FieldKey.fromParts("Sire")); - visible.add(sireIndex < 0 ? visible.size() : sireIndex + 1, col.getFieldKey()); + int dateIndex = visible.indexOf(FieldKey.fromParts("date")); + visible.add(dateIndex < 0 ? visible.size() : dateIndex + 1, col.getFieldKey()); ti.setDefaultVisibleColumns(visible); } 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..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 @@ -793,12 +793,14 @@ 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"); + int conceptionDays = 160; + 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 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")); @@ -911,8 +913,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 +946,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", "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(); @@ -1017,9 +1019,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", "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(); @@ -1101,8 +1103,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", "performedby", 1004)); conception.execute(getApiHelper().getConnection(), getContainerPath()); gotoEnterData(); @@ -1128,8 +1130,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 +1160,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",