diff --git a/nbri_ehr/resources/data/death_reason.tsv b/nbri_ehr/resources/data/death_reason.tsv deleted file mode 100644 index 0b39f78..0000000 --- a/nbri_ehr/resources/data/death_reason.tsv +++ /dev/null @@ -1,6 +0,0 @@ -value title -1 Euthanasia (clinical) -2 Euthaniasia (project) -3 Morbid -4 Natural Death -5 Program Management \ No newline at end of file diff --git a/nbri_ehr/resources/data/death_type.tsv b/nbri_ehr/resources/data/death_type.tsv index 700a88d..61853c9 100644 --- a/nbri_ehr/resources/data/death_type.tsv +++ b/nbri_ehr/resources/data/death_type.tsv @@ -1,14 +1,14 @@ -value title sort_order -A Experimental 1 +value title sort_order date_disabled +A Experimental 1 2026-09-03 D Spontaneous/Normal 2 -F Fetal 3 -FD Fetal Death 4 -FL Fetal Live 5 -FN Fetal found at necropsy 6 -FX Fetal experimental 7 +F Fetal 3 2026-09-03 +FD Fetal Death 4 2026-09-03 +FL Fetal Live 5 2026-09-03 +FN Fetal found at necropsy 6 2026-09-03 +FX Fetal experimental 7 2026-09-03 K Cull (scheduled) 8 M Medical cull (non-scheduled) 9 -ND Non-vaginal (C-section) dead 10 -NT Not pregnant at assessment 11 +ND Non-vaginal (C-section) dead 10 2026-09-03 +NT Not pregnant at assessment 11 2026-09-03 S Cull 12 X Experimental 13 \ No newline at end of file diff --git a/nbri_ehr/resources/data/editable_lookups.tsv b/nbri_ehr/resources/data/editable_lookups.tsv index c12dbd0..0d76eba 100644 --- a/nbri_ehr/resources/data/editable_lookups.tsv +++ b/nbri_ehr/resources/data/editable_lookups.tsv @@ -40,7 +40,6 @@ ehr_lookups country Colony Management Country ehr_lookups country_category Colony Management Country Category ehr_lookups daily_enrich_codes Behavior Daily enrichment codes. ehr_lookups data_category Clinical Data Categories Used in datasets. -ehr_lookups death_reason Colony Management Death Reason ehr_lookups death_type Colony Management Death Type Death type codes. ehr_lookups delivery_mode Colony Management Delivery Mode ehr_lookups delivery_state Colony Management Delivery State diff --git a/nbri_ehr/resources/data/lookup_sets.tsv b/nbri_ehr/resources/data/lookup_sets.tsv index 6cd8cba..c265799 100644 --- a/nbri_ehr/resources/data/lookup_sets.tsv +++ b/nbri_ehr/resources/data/lookup_sets.tsv @@ -31,7 +31,6 @@ country Country value title country_category Country Category value title daily_enrich_codes Daily Enrichment Codes value data_category Data Category Field Values value -death_reason Death Reason value death_type Death Type value title delivery_mode Delivery Mode value title delivery_state Delivery State value title diff --git a/nbri_ehr/resources/data/lookupsManifest.tsv b/nbri_ehr/resources/data/lookupsManifest.tsv index 19aa5a4..cd41275 100644 --- a/nbri_ehr/resources/data/lookupsManifest.tsv +++ b/nbri_ehr/resources/data/lookupsManifest.tsv @@ -35,7 +35,6 @@ country country_category daily_enrich_codes data_category -death_reason death_type delivery_mode delivery_state diff --git a/nbri_ehr/resources/data/lookupsManifestTest.tsv b/nbri_ehr/resources/data/lookupsManifestTest.tsv index 8d028e6..7f1db9d 100644 --- a/nbri_ehr/resources/data/lookupsManifestTest.tsv +++ b/nbri_ehr/resources/data/lookupsManifestTest.tsv @@ -35,7 +35,6 @@ country country_category daily_enrich_codes data_category -death_reason death_type delivery_mode delivery_state diff --git a/nbri_ehr/resources/queries/nbri_ehr/Conception.js b/nbri_ehr/resources/queries/nbri_ehr/Conception.js index 72d57b2..9dd9a9c 100644 --- a/nbri_ehr/resources/queries/nbri_ehr/Conception.js +++ b/nbri_ehr/resources/queries/nbri_ehr/Conception.js @@ -1 +1,45 @@ -require("ehr/triggers").initScript(this); \ No newline at end of file +/* + * 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 index 32e2b50..df0d1ca 100644 --- a/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml +++ b/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml @@ -2,6 +2,7 @@ + Conception Records diff --git a/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql b/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql index 68b07b4..599a1ed 100644 --- a/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql +++ b/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql @@ -9,17 +9,19 @@ SELECT c.ConceptDate, c.Estimated, c.Sire, + c.isActive, CASE + WHEN c.isActive = true THEN 'Unknown' WHEN b.conceptId IS NOT NULL THEN 'Live Birth' - WHEN po.conceptId IS NOT NULL THEN COALESCE(po.result, 'Unknown') - ELSE 'Unknown' + ELSE COALESCE(po.result, 'Unknown') END AS conceptionOutcome, b.offspring, c.Remark, c.QCState AS qcstate FROM Conception c --- a conception yields at most one birth; the aggregate only guards against duplicates the birth trigger warns about but does not block -LEFT JOIN (SELECT b.conceptId, MAX(b.Id) AS offspring FROM study.birth b WHERE b.conceptId IS NOT NULL GROUP BY b.conceptId) b +-- 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 -LEFT JOIN (SELECT p.conceptId, MAX(p.result.title) AS result FROM study.pregnancy p WHERE p.conceptId IS NOT NULL GROUP BY p.conceptId) po +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 diff --git a/nbri_ehr/resources/queries/study/activeConceptions.sql b/nbri_ehr/resources/queries/study/activeConceptions.sql new file mode 100644 index 0000000..3fd6f1f --- /dev/null +++ b/nbri_ehr/resources/queries/study/activeConceptions.sql @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 diff --git a/nbri_ehr/resources/queries/study/animal_group_members.query.xml b/nbri_ehr/resources/queries/study/animal_group_members.query.xml index 09a5157..3f4678e 100644 --- a/nbri_ehr/resources/queries/study/animal_group_members.query.xml +++ b/nbri_ehr/resources/queries/study/animal_group_members.query.xml @@ -7,9 +7,11 @@ Date Added + DateTime Date Removed + DateTime false diff --git a/nbri_ehr/resources/queries/study/arrival.js b/nbri_ehr/resources/queries/study/arrival.js index d374944..9ca4acf 100644 --- a/nbri_ehr/resources/queries/study/arrival.js +++ b/nbri_ehr/resources/queries/study/arrival.js @@ -8,6 +8,11 @@ require("ehr/triggers").initScript(this); var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); var idsToSync = []; +// generation 0 is a real value, so emptiness cannot be tested by truthiness the way the other demographics fields test it +function isBlankGeneration(value) { + return value === null || value === undefined || value === ''; +} + // opens one assignment record against the animal being entered; each dataset carries the assignment under its own field function createAssignment(scriptErrors, dataset, fieldName, value, row) { if (!value) @@ -46,6 +51,11 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even EHR.Server.Utils.addError(scriptErrors, 'Id', 'Animal Id ' + row.Id + ' is already in use. Please use a different Id.', 'ERROR'); } + // only a form entry can be held to a generation: the form seeds it to 0, while a study import has no such column to carry + if (!row.rearrival && !helper.isETL() && helper.isEHRDataEntry() && helper.getEvent() == 'insert' && isBlankGeneration(row['Id/demographics/generation'])) { + EHR.Server.Utils.addError(scriptErrors, 'Id/demographics/generation', 'Generation is required', 'ERROR'); + } + if (row.eventDate) { row.date = row.eventDate; } @@ -69,6 +79,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even row.gender = row['Id/demographics/gender'] || null; row.geographic_origin = row['Id/demographics/geographic_origin'] || null; row.socialCode = row['Id/demographics/socialCode'] || null; + row.generation = isBlankGeneration(row['Id/demographics/generation']) ? null : parseInt(row['Id/demographics/generation'], 10); if (row.QCStateLabel) { row.qcstate = helper.getJavaHelper().getQCStateForLabel(row.QCStateLabel).getRowId(); @@ -152,6 +163,12 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even hasUpdates = true; } + if (!isBlankGeneration(row.generation) && row.generation !== data.generation) + { + obj.generation = row.generation; + hasUpdates = true; + } + if (row.sire && row.sire !== data.sire) { obj.sire = row.sire; diff --git a/nbri_ehr/resources/queries/study/arrival.query.xml b/nbri_ehr/resources/queries/study/arrival.query.xml index 84e30cc..eb3af07 100644 --- a/nbri_ehr/resources/queries/study/arrival.query.xml +++ b/nbri_ehr/resources/queries/study/arrival.query.xml @@ -11,6 +11,7 @@ Arrival Date + DateTime Arrival Type diff --git a/nbri_ehr/resources/queries/study/assignment.query.xml b/nbri_ehr/resources/queries/study/assignment.query.xml index df55a2e..6360ef8 100644 --- a/nbri_ehr/resources/queries/study/assignment.query.xml +++ b/nbri_ehr/resources/queries/study/assignment.query.xml @@ -7,7 +7,9 @@ DateTime - + + DateTime + ehr diff --git a/nbri_ehr/resources/queries/study/birth.js b/nbri_ehr/resources/queries/study/birth.js index 6531bc9..fa4f20a 100644 --- a/nbri_ehr/resources/queries/study/birth.js +++ b/nbri_ehr/resources/queries/study/birth.js @@ -9,10 +9,19 @@ EHR.Server.Utils = require("ehr/utils").EHR.Server.Utils; var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); var idsToSync = []; +// dams whose conception this save closes. The birth row announces the newborn, so the dam's cached Pregnant value +// would otherwise keep listing a conception that is no longer open. +var damsToSync = []; + // conception ids claimed by the rows of this save that have already been validated. Rows entered together are not in // study.birth yet when each one is checked, so this is the only way the one-birth-per-conception rule can see them. var conceptIdsInSave = []; +// generation 0 is a real value, so emptiness cannot be tested by truthiness the way the other demographics fields test it +function isBlankGeneration(value) { + return value === null || value === undefined || value === ''; +} + // opens one assignment record against the animal being entered; each dataset carries the assignment under its own field function createAssignment(scriptErrors, dataset, fieldName, value, row) { if (!value) @@ -34,6 +43,17 @@ function createAssignment(scriptErrors, dataset, fieldName, value, row) { } } +// resolves the dam of a conception so the birth can announce her; the conception carries the dam, the birth row does not +function addConceptionDam(conceptId) { + if (!conceptId) + return; + + var dam = triggerHelper.getConceptionDam(conceptId); + if (dam && damsToSync.indexOf(dam) === -1) { + damsToSync.push(dam); + } +} + function onInit(event, helper){ helper.setScriptOptions({ allowAnyId: true, @@ -43,13 +63,13 @@ function onInit(event, helper){ skipHousingCheck: true, announceAllModifiedParticipants: true, allowDatesInDistantPast: true, - removeTimeFromDate: true, skipAssignmentCheck: true, }); // the script scope can outlive a single save, so never inherit ids from a prior one idsToSync = []; conceptIdsInSave = []; + damsToSync = []; helper.decodeExtraContextProperty('birthsInTransaction'); } @@ -65,6 +85,16 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } idsToSync = []; } + + if (damsToSync.length) { + triggerHelper.reportDataChange('nbri_ehr', 'Conception', damsToSync); + damsToSync = []; + } +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_DELETE, 'study', 'birth', function(helper, scriptErrors, row) { + // deleting the birth reopens its conception + addConceptionDam(row.conceptId); }); EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_UPSERT, 'study', 'birth', function(helper, scriptErrors, row, oldRow) { @@ -96,6 +126,13 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } } + if (!helper.isETL() && !helper.isValidateOnly()) { + addConceptionDam(row.conceptId); + + // a re-pointed birth reopens the conception it used to claim + addConceptionDam(oldRow ? oldRow.conceptId : null); + } + if (!helper.isETL()) { if (row.QCStateLabel) { @@ -146,6 +183,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even birth: row.date || null, gender: row['Id/demographics/gender'] || null, socialCode: row['Id/demographics/socialCode'] || null, + generation: isBlankGeneration(row['Id/demographics/generation']) ? null : parseInt(row['Id/demographics/generation'], 10), taskid: row.taskid, remark: row.remark, QCStateLabel: row.QCStateLabel, @@ -161,6 +199,19 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even obj.species = helper.getJavaHelper().getSpecies(obj.dam); } + // the conception window fills this in, so a blank one means a save that bypassed the form + if (isBlankGeneration(obj.generation)) { + var damGeneration = obj.dam ? triggerHelper.getGeneration(obj.dam) : null; + if (damGeneration === null) { + var generationWarning = obj.dam + ? 'No generation is recorded for dam ' + obj.dam + ', so this birth was recorded as generation 1' + : 'This birth record has no dam, so it was recorded as generation 1'; + EHR.Server.Utils.addError(scriptErrors, 'Id/demographics/generation', generationWarning, 'WARN'); + } + + obj.generation = (damGeneration === null ? 0 : damGeneration) + 1; + } + if (!oldRow) { //if not already present, we insert into demographics helper.getJavaHelper().createDemographicsRecord(row.Id, obj, extraDemographicsFieldMappings); @@ -198,6 +249,11 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even hasUpdates = true; } + if (!isBlankGeneration(obj.generation) && obj.generation !== data.generation) { + record.generation = obj.generation; + hasUpdates = true; + } + if (obj.performedby && obj.performedby !== data.performedby) { record.performedby = obj.performedby; hasUpdates = true; diff --git a/nbri_ehr/resources/queries/study/birth.query.xml b/nbri_ehr/resources/queries/study/birth.query.xml index f424994..fc7174f 100644 --- a/nbri_ehr/resources/queries/study/birth.query.xml +++ b/nbri_ehr/resources/queries/study/birth.query.xml @@ -11,6 +11,7 @@ Birth Date + DateTime Conception Id diff --git a/nbri_ehr/resources/queries/study/clinical_observations.js b/nbri_ehr/resources/queries/study/clinical_observations.js index 9e54948..ddafdc5 100644 --- a/nbri_ehr/resources/queries/study/clinical_observations.js +++ b/nbri_ehr/resources/queries/study/clinical_observations.js @@ -45,6 +45,13 @@ function onUpsert(helper, scriptErrors, row, oldRow) { EHR.Server.Utils.addError(scriptErrors, 'remark', "You selected 'Yes' for " + row.category + ", please explain in the Remark", "WARN"); } + // Always derive the type from the observation type's category rather than trusting the incoming value. + // The Observations form leaves it blank because it offers every type; the other forms set it explicitly, + // but their type pickers are filtered to the categories that agree with the value they set, so deriving + // here gives them the same answer. Deriving unconditionally also re-derives when a re-opened draft or a + // saved template carries a type left over from a different category. + row.type = triggerHelper.getObservationTypeCategory(row.category) === 'Behavior' ? 'Behavior' : 'Clinical'; + // Handle scheduled observations if (!helper.isValidateOnly() && row.scheduledDate) { var qc; diff --git a/nbri_ehr/resources/queries/study/deathNotification.sql b/nbri_ehr/resources/queries/study/deathNotification.sql index 86f8800..51f5906 100644 --- a/nbri_ehr/resources/queries/study/deathNotification.sql +++ b/nbri_ehr/resources/queries/study/deathNotification.sql @@ -8,6 +8,5 @@ SELECT Id, date, taskid, - performedBy.DisplayName AS performedBy, - reason.title AS reason + performedBy.DisplayName AS performedBy FROM study.deaths \ No newline at end of file diff --git a/nbri_ehr/resources/queries/study/deaths.js b/nbri_ehr/resources/queries/study/deaths.js index fc9b3e9..87dd9ec 100644 --- a/nbri_ehr/resources/queries/study/deaths.js +++ b/nbri_ehr/resources/queries/study/deaths.js @@ -10,6 +10,10 @@ var idMap = {}; var deathIdMap = {}; var idsToSync = []; +// QC states that mean a death has been declared. Leaving draft for anything else -- 'Delete Requested', a denied +// request -- is not a declaration, so it must not notify or close out the animal's procedure orders. +var NOTIFY_STATES = ['REQUEST: PENDING', 'REVIEW REQUIRED', 'COMPLETED']; + function onInit(event, helper){ // the script scope can outlive a single save, so never inherit ids from a prior one @@ -185,24 +189,33 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even idsToSync = []; } + // A delete arrives here as the deleted row with a null oldRow, which otherwise reads as a draft leaving draft. + if (event === 'delete') + return; + var rows = helper.getRows() || []; + var idsToComplete = []; for (var i = 0; i < rows.length; i++) { var row = rows[i].row; var oldRow = rows[i].oldRow; - // Notification will get sent when: - // 1) a brand-new row saved directly as 'Request: Pending' (i.e., when a user clicks 'Submit Death'), or - // 2) a draft death record moving from 'In Progress' to 'Request: Pending'. - if (!helper.isETL() && - row && row.Id && - row.QCStateLabel && - row.QCStateLabel.toUpperCase() === 'REQUEST: PENDING' && - (!oldRow || !oldRow.QCStateLabel || oldRow.QCStateLabel.toUpperCase() === 'IN PROGRESS')) { + if (helper.isETL() || !row || !row.Id || !row.QCStateLabel) + continue; + + // Notify once, on the first save that leaves draft: 'Submit Death' lands on 'Request: Pending', but a death entered alongside its necropsy goes straight to 'Review Required' or 'Completed'. + var wasDraft = !oldRow || !oldRow.QCStateLabel || oldRow.QCStateLabel.toUpperCase() === 'IN PROGRESS'; + if (wasDraft && NOTIFY_STATES.indexOf(row.QCStateLabel.toUpperCase()) > -1) { console.log("Sending NBRI Death Notification") triggerHelper.sendDeathNotification(row.Id); - console.log("Updating Procedure Orders to Completed for Animal: " + row.Id + "") - triggerHelper.updateProcedureOrdersToCompleted([row.Id]); + if (idsToComplete.indexOf(row.Id) === -1) + idsToComplete.push(row.Id); } } + + // One pass for the whole save: the helper filters and updates in bulk, so a call per row multiplies round trips. + if (idsToComplete.length) { + console.log("Updating Procedure Orders to Completed for: " + idsToComplete.join(', ')) + triggerHelper.updateProcedureOrdersToCompleted(idsToComplete); + } }); \ No newline at end of file diff --git a/nbri_ehr/resources/queries/study/deaths.query.xml b/nbri_ehr/resources/queries/study/deaths.query.xml index e28e2f0..94fd06a 100644 --- a/nbri_ehr/resources/queries/study/deaths.query.xml +++ b/nbri_ehr/resources/queries/study/deaths.query.xml @@ -6,6 +6,7 @@ Death Date + DateTime Death Type @@ -16,15 +17,6 @@ title - - Disposition - - ehr_lookups - death_reason - value - title - - Death Weight (kg) diff --git a/nbri_ehr/resources/queries/study/deaths/.qview.xml b/nbri_ehr/resources/queries/study/deaths/.qview.xml index 56eecec..eb7c293 100644 --- a/nbri_ehr/resources/queries/study/deaths/.qview.xml +++ b/nbri_ehr/resources/queries/study/deaths/.qview.xml @@ -7,7 +7,6 @@ - diff --git a/nbri_ehr/resources/queries/study/demographics.query.xml b/nbri_ehr/resources/queries/study/demographics.query.xml index 0323b4a..dd6db15 100644 --- a/nbri_ehr/resources/queries/study/demographics.query.xml +++ b/nbri_ehr/resources/queries/study/demographics.query.xml @@ -13,7 +13,7 @@ Species - Date + DateTime Birth /query/executeQuery.view? schemaName=study& @@ -22,7 +22,7 @@ - Date + DateTime Death /query/executeQuery.view? schemaName=study& @@ -100,6 +100,9 @@ title + + Generation + CITES diff --git a/nbri_ehr/resources/queries/study/demographics/.qview.xml b/nbri_ehr/resources/queries/study/demographics/.qview.xml index 5f80b01..1fcb392 100644 --- a/nbri_ehr/resources/queries/study/demographics/.qview.xml +++ b/nbri_ehr/resources/queries/study/demographics/.qview.xml @@ -13,6 +13,7 @@ + diff --git a/nbri_ehr/resources/queries/study/demographicsCagemates.sql b/nbri_ehr/resources/queries/study/demographicsCagemates.sql index 0450dc2..4be95d9 100644 --- a/nbri_ehr/resources/queries/study/demographicsCagemates.sql +++ b/nbri_ehr/resources/queries/study/demographicsCagemates.sql @@ -25,10 +25,12 @@ JOIN study.housing h2 -- cagemates. Room is not consulted, since it is derived from this same id and so can never distinguish two rows. ON (h.cage = h2.cage AND h2.Id.demographics.calculated_status = 'Alive' - AND h2.enddateTimeCoalesced >= now() + AND h2.isActive = true AND h2.qcstate.publicdata = true) -WHERE h.enddateTimeCoalesced >= now() +-- a null location never resolved, so the row gets no cagemates rather than grouping with every other unresolved row +WHERE h.cage IS NOT NULL +AND h.isActive = true AND h.qcstate.publicdata = true GROUP BY h.id, h.cage diff --git a/nbri_ehr/resources/queries/study/housing.query.xml b/nbri_ehr/resources/queries/study/housing.query.xml index e1a21a3..9c8a81f 100644 --- a/nbri_ehr/resources/queries/study/housing.query.xml +++ b/nbri_ehr/resources/queries/study/housing.query.xml @@ -6,6 +6,7 @@ In Date + DateTime true @@ -13,6 +14,7 @@ true true Out Date + DateTime Location diff --git a/nbri_ehr/resources/queries/study/necropsy.query.xml b/nbri_ehr/resources/queries/study/necropsy.query.xml index 35290a8..f8054a1 100644 --- a/nbri_ehr/resources/queries/study/necropsy.query.xml +++ b/nbri_ehr/resources/queries/study/necropsy.query.xml @@ -5,6 +5,7 @@ Exam Date + DateTime Category diff --git a/nbri_ehr/resources/queries/study/pregnancy.js b/nbri_ehr/resources/queries/study/pregnancy.js index ab4dc7b..c989278 100644 --- a/nbri_ehr/resources/queries/study/pregnancy.js +++ b/nbri_ehr/resources/queries/study/pregnancy.js @@ -8,6 +8,26 @@ EHR.Server.Utils = require("ehr/utils").EHR.Server.Utils; var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); +// dams whose conception this save claims or frees. The outcome announces its own Id, which is the dam only when the +// record was entered against her, so resolve the dam from the conception instead of trusting row.Id. +var damsToSync = []; + +// resolves the dam of a conception so the outcome can announce her; the conception carries the dam, the outcome row does not +function addConceptionDam(conceptId) { + if (!conceptId) + return; + + var dam = triggerHelper.getConceptionDam(conceptId); + if (dam && damsToSync.indexOf(dam) === -1) { + damsToSync.push(dam); + } +} + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.INIT, 'study', 'pregnancy', function(event, helper){ + // the script scope can outlive a single save, so never inherit dams from a prior one + damsToSync = []; +}); + 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) { @@ -24,6 +44,26 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even EHR.Server.Utils.addError(scriptErrors, 'conceptId', 'This conception Id is already used by a birth record', 'INFO'); } } + + // validation never reaches COMPLETE, so resolving dams during it is a wasted query per row + if (!helper.isETL() && !helper.isValidateOnly()) { + addConceptionDam(row.conceptId); + + // a re-pointed or cleared outcome reopens the conception it used to claim + addConceptionDam(oldRow ? oldRow.conceptId : null); + } +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_DELETE, 'study', 'pregnancy', function(helper, scriptErrors, row) { + // deleting the outcome reopens its conception + addConceptionDam(row.conceptId); +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.COMPLETE, 'study', 'pregnancy', function(event, errors, helper){ + if (damsToSync.length) { + triggerHelper.reportDataChange('nbri_ehr', 'Conception', damsToSync); + damsToSync = []; + } }); EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.ON_BECOME_PUBLIC, 'study', 'pregnancy', function(scriptErrors, helper, row, oldRow) { @@ -36,4 +76,4 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } triggerHelper.sendPregnancyOutcomeNotification(row.Id, outcomeRec); } -}); \ No newline at end of file +}); diff --git a/nbri_ehr/resources/queries/study/pregnancy.query.xml b/nbri_ehr/resources/queries/study/pregnancy.query.xml index a97139a..5ee2a9f 100644 --- a/nbri_ehr/resources/queries/study/pregnancy.query.xml +++ b/nbri_ehr/resources/queries/study/pregnancy.query.xml @@ -29,6 +29,16 @@ ConceptId + + Delivery Mode + false + + ehr_lookups + delivery_mode + value + title + +
diff --git a/nbri_ehr/resources/queries/study/protocolAssignment.query.xml b/nbri_ehr/resources/queries/study/protocolAssignment.query.xml index d95eb07..ccd46a8 100644 --- a/nbri_ehr/resources/queries/study/protocolAssignment.query.xml +++ b/nbri_ehr/resources/queries/study/protocolAssignment.query.xml @@ -4,8 +4,12 @@ - - + + DateTime + + + DateTime + true diff --git a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml index eed9f6a..b036dfa 100644 --- a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml +++ b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml @@ -509,6 +509,9 @@ varchar + + integer + varchar @@ -561,9 +564,6 @@ http://cpas.labkey.com/Study#VisitDate http://cpas.labkey.com/Study#VisitDate - - varchar - double @@ -1367,7 +1367,6 @@ http://cpas.labkey.com/Study#VisitDate - Type varchar diff --git a/nbri_ehr/resources/scripts/nbri_triggers.js b/nbri_ehr/resources/scripts/nbri_triggers.js index 320c63f..2c806fe 100644 --- a/nbri_ehr/resources/scripts/nbri_triggers.js +++ b/nbri_ehr/resources/scripts/nbri_triggers.js @@ -58,7 +58,9 @@ exports.init = function (EHR) { // group memberships are routinely backdated, so historical dates must not raise a warning helper.setScriptOptions({ requiresStatusRecalc: false, - allowDatesInDistantPast: true + allowDatesInDistantPast: true, + // Overrides the shared animal_group_members script, which sets this true. + removeTimeFromDate: false }); }); diff --git a/nbri_ehr/resources/views/necropsy.html b/nbri_ehr/resources/views/necropsy.html index 9952555..7e7c544 100644 --- a/nbri_ehr/resources/views/necropsy.html +++ b/nbri_ehr/resources/views/necropsy.html @@ -62,7 +62,7 @@ schemaName: 'study', queryName: 'deaths', filterArray: filterArray, - columns: 'Id,Id/demographics/species,Id/demographics/gender,date,reason,deathWeight,Id/lastProtocol/protocol,Id/lastProject/project', + columns: 'Id,Id/demographics/species,Id/demographics/gender,date,deathWeight,Id/lastProtocol/protocol,Id/lastProject/project', }, title: 'Death', renderTo: 'animalDeath', diff --git a/nbri_ehr/resources/web/nbri_ehr/buttons/clinicalObsGridButton.js b/nbri_ehr/resources/web/nbri_ehr/buttons/clinicalObsGridButton.js deleted file mode 100644 index be6c858..0000000 --- a/nbri_ehr/resources/web/nbri_ehr/buttons/clinicalObsGridButton.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2026 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -EHR.DataEntryUtils.registerGridButton('NBRI_AUTO_POPULATE_DAILY_OBS', function(config){ - return Ext4.Object.merge({ - text: 'Auto Populate Clinical Obs', - xtype: 'button', - hidden: true, - listeners: { - render: function(btn){ - const id = LABKEY.ActionURL.getParameter('id'); - const caseid = LABKEY.ActionURL.getParameter('caseid'); - const scheduledDate = LABKEY.ActionURL.getParameter('scheduledDate'); - const scheduled = id && caseid && scheduledDate; - - LABKEY.Query.selectRows({ - schemaName: 'ehr', - queryName: 'observation_types', - ignoreFilter: true, - success: function (results) { - var grid = btn.up('gridpanel'); - if (grid?.store?.data?.getCount() === 0) { - if (results?.rows?.length > 0) { - for (var i = 0; i < results.rows.length; i++) { - var row = results.rows[i]; - if (row.value === 'Verified Id?' || row.value === 'Stool' || row.value === 'Activity' || - row.value === 'Appetite' || row.value === 'Hydration' || row.value === 'BCS') { - - var newRecord = grid.store.createModel({}); - newRecord.set({ - category: row.value, - }); - - if (scheduled) { - newRecord.set('Id', id); - newRecord.set('caseid', caseid); - newRecord.set('scheduledDate', scheduledDate); - } - grid.store.add(newRecord); - } - } - - if (scheduled) { - this.addEvents('animalchange'); - this.enableBubble('animalchange'); - this.fireEvent('animalchange', id); - grid.fireEvent('panelDataChange'); - } - } - } - }, - scope: this - }); - } - } - }, config); -}); \ No newline at end of file diff --git a/nbri_ehr/resources/web/nbri_ehr/buttons/deathNecropsyButtons.js b/nbri_ehr/resources/web/nbri_ehr/buttons/deathNecropsyButtons.js index efee481..b07a7a5 100644 --- a/nbri_ehr/resources/web/nbri_ehr/buttons/deathNecropsyButtons.js +++ b/nbri_ehr/resources/web/nbri_ehr/buttons/deathNecropsyButtons.js @@ -101,7 +101,7 @@ Ext4.define('NBRI_EHR.window.DeathNecropsySubmitForReviewWindow', { text: 'Cancel', scope: this, handler: function(btn){ - btn.up('window').hide(); + btn.up('window').close(); } }], items: [{ @@ -127,8 +127,10 @@ Ext4.define('NBRI_EHR.window.DeathNecropsySubmitForReviewWindow', { value: this.getDefaultRecipient(), displayField: 'DisplayName', valueField: 'UserId', + // No global 'id' here: a reopened window would adopt the previous window's element and render a second combo. itemId: 'assignedTo', - id: 'assignedTo', + // Ext derives the input's name from the component id when 'name' is absent, so set it explicitly rather than leaning on the id. + name: 'assignedTo', anyMatch: true, caseSensitive: false, }] diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js index 9883d37..af420c2 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js @@ -20,7 +20,8 @@ EHR.model.DataModelManager.registerMetadata('Arrival', { byQuery: { 'study.arrival': { 'cage': { - // allowBlank: false, + allowBlank: false, + nullable: false, columnConfig: { fixed: true, width: 200 @@ -55,6 +56,21 @@ EHR.model.DataModelManager.registerMetadata('Arrival', { width: 200 } }, + // an arriving animal establishes its own lineage, so it starts at generation 0 + 'Id/demographics/generation': { + allowBlank: false, + nullable: false, + getInitialValue: function(v) { + return Ext4.isEmpty(v) ? 0 : v; + }, + editorConfig: { + minValue: 0 + }, + columnConfig: { + fixed: true, + width: 120 + } + }, // an animal joins the colony already assigned to a project, a protocol and a group; the trigger script // opens the matching assignment record for each one project: { diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js index bed0ab7..a961152 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js @@ -59,7 +59,8 @@ EHR.model.DataModelManager.registerMetadata('Birth', { } }, 'cage': { - // allowBlank: false, + allowBlank: false, + nullable: false, columnConfig: { fixed: true, width: 200 @@ -145,6 +146,17 @@ EHR.model.DataModelManager.registerMetadata('Birth', { columnConfig: { width: 200 } + }, + // derived from the dam by the conception window, but left editable so it can be corrected by hand + 'Id/demographics/generation': { + allowBlank: false, + nullable: false, + editorConfig: { + minValue: 0 + }, + columnConfig: { + width: 120 + } } } } diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Death.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Death.js index 84eb22a..39b7865 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Death.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Death.js @@ -36,11 +36,9 @@ EHR.model.DataModelManager.registerMetadata('Death', { nullable: false, columnConfig: { width: 160 - } - }, - reason: { - columnConfig: { - width: 160 + }, + lookup: { + filterArray: [LABKEY.Filter.create('date_disabled', null, LABKEY.Filter.Types.ISBLANK)] } }, remark: { diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/ObsDefaults.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/ObsDefaults.js index bd9f4ce..3569d57 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/ObsDefaults.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/ObsDefaults.js @@ -6,6 +6,13 @@ EHR.model.DataModelManager.registerMetadata('ObsDefaults', { byQuery: { 'study.clinical_observations': { + // This form offers every observation type, so it can't know the observation's type up front. + // Clearing the default inherited from ClinicalDefaults lets the trigger script derive it + // from the selected type's category. + type: { + hidden: true, + defaultValue: null + }, category: { lookup: { columns: 'value,description', diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Pregnancy.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Pregnancy.js index c22335d..39df64e 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Pregnancy.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Pregnancy.js @@ -12,9 +12,6 @@ EHR.model.DataModelManager.registerMetadata('Pregnancy', { project: { hidden: true, }, - type: { - hidden: true, - }, diagnosis: { hidden: true, }, @@ -26,9 +23,18 @@ EHR.model.DataModelManager.registerMetadata('Pregnancy', { nullable: false, }, conceptId: { + allowBlank: false, + nullable: false, columnConfig: { width: 150 } + }, + // shares the delivery_mode lookup with study.birth, but is optional here: an outcome can be recorded + // before the delivery mode is known + type: { + columnConfig: { + width: 200 + } } }, diff --git a/nbri_ehr/resources/web/nbri_ehr/panel/AnimalDetailsPanel.js b/nbri_ehr/resources/web/nbri_ehr/panel/AnimalDetailsPanel.js index e22d7e5..36ef139 100644 --- a/nbri_ehr/resources/web/nbri_ehr/panel/AnimalDetailsPanel.js +++ b/nbri_ehr/resources/web/nbri_ehr/panel/AnimalDetailsPanel.js @@ -138,6 +138,9 @@ Ext4.define('NBRI_EHR.panel.AnimalDetailsPanel', { },{ fieldLabel: 'Cagemates', name: 'cagemates' + },{ + fieldLabel: 'Pregnant', + name: 'pregnant' },{ fieldLabel: 'Weight', name: 'weights' diff --git a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js index 0ee4d92..9bcccb7 100644 --- a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js +++ b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js @@ -85,10 +85,6 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { xtype: 'displayfield', fieldLabel: 'Source', name: 'source' - },{ - xtype: 'displayfield', - fieldLabel: 'Prev Id', - name: 'prev_id' }] },{ xtype: 'container', @@ -133,6 +129,10 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { xtype: 'displayfield', fieldLabel: 'Last TB', name: 'lastTB' + },{ + xtype: 'displayfield', + fieldLabel: 'Pregnant', + name: 'pregnant' },{ xtype: 'displayfield', fieldLabel: 'Weights', @@ -391,4 +391,32 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { toSet['parents'] = 'No data'; } }, + + appendDataResults: function(toSet, results, id){ + this.callParent(arguments); + this.appendPregnancy(toSet, results); + }, + + appendPregnancy: function(toSet, results){ + var records = results ? results.getData()['activeConceptions'] : null; + // getEHRContext returns null when the study container property is unset; fall back to the current container + var ctx = EHR.Utils.getEHRContext() || {}; + var values = []; + + if (Ext4.isArray(records)){ + Ext4.each(records, function(record){ + 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 + }); + values.push('' + LABKEY.Utils.encodeHtml(conceptId) + ''); + } + }, this); + } + + toSet['pregnant'] = values.length ? values.join('
') : 'No'; + }, }); \ 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 9c33be5..3557aa7 100644 --- a/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js +++ b/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js @@ -89,8 +89,8 @@ Ext4.define('NBRI_EHR.window.StartWithConceptionWindow', { var sire = record.get('Sire'); btn.disable(); - this.getSpecies(dam, function(species, speciesError){ - this.applyConception(conceptId, dam, sire, species); + this.getDamAttributes(dam, function(damAttributes, speciesError){ + this.applyConception(conceptId, dam, sire, damAttributes.species, damAttributes.generation); btn.enable(); this.close(); @@ -102,47 +102,60 @@ Ext4.define('NBRI_EHR.window.StartWithConceptionWindow', { }, this); }, - // the species of the offspring is inferred from the dam of the conception. When it cannot be determined the - // callback receives a message explaining why, rather than a null that is indistinguishable from an unset field. - getSpecies: function(dam, callback, scope){ + // the species and generation of the offspring are both inferred from the dam of the conception. A species that + // cannot be determined comes back with a message explaining why, rather than a null that is indistinguishable from + // an unset field; a dam with no generation is not an error and simply leaves the offspring at generation 1. + getDamAttributes: function(dam, callback, scope){ if (!dam){ - callback.call(scope, null, 'The conception record has no dam, so the species could not be determined.'); + callback.call(scope, {species: null, generation: this.nextGeneration(null)}, + 'The conception record has no dam, so the species could not be determined.'); return; } LABKEY.Query.selectRows({ schemaName: 'study', queryName: 'demographics', - columns: 'Id,species', + columns: 'Id,species,generation', filterArray: [LABKEY.Filter.create('Id', dam, LABKEY.Filter.Types.EQUAL)], scope: this, success: function(results){ var rows = (results && results.rows) || []; if (!rows.length){ - callback.call(scope, null, 'No demographics record was found for dam ' + dam + '.'); + callback.call(scope, {species: null, generation: this.nextGeneration(null)}, + 'No demographics record was found for dam ' + dam + '.'); return; } + var generation = this.nextGeneration(rows[0].generation); + if (!rows[0].species){ - callback.call(scope, null, 'No species is recorded on the demographics record for dam ' + dam + '.'); + callback.call(scope, {species: null, generation: generation}, + 'No species is recorded on the demographics record for dam ' + dam + '.'); return; } - callback.call(scope, rows[0].species); + callback.call(scope, {species: rows[0].species, generation: generation}); }, failure: function(error){ console.error(error); - callback.call(scope, null, 'Unable to look up the species of dam ' + dam + ': ' + ((error && error.exception) || 'the query failed') + '.'); + callback.call(scope, {species: null, generation: this.nextGeneration(null)}, + 'Unable to look up the species of dam ' + dam + ': ' + ((error && error.exception) || 'the query failed') + '.'); } }); }, - applyConception: function(conceptId, dam, sire, species){ + // a founder dam carries no generation of her own, so her offspring start the count at 1 + nextGeneration: function(damGeneration){ + return (damGeneration == null ? 0 : damGeneration) + 1; + }, + + applyConception: function(conceptId, dam, sire, species, generation){ var values = { conceptId: conceptId, 'Id/demographics/dam': dam, 'Id/demographics/sire': sire, - 'Id/demographics/species': species + 'Id/demographics/species': species, + 'Id/demographics/generation': generation }; // only the fields the conception owns are written, so anything already entered on the row survives 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 0b87214..06c078e 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java @@ -47,6 +47,7 @@ import org.labkey.nbri_ehr.dataentry.form.*; import org.labkey.nbri_ehr.demographics.ActiveAssignmentsDemographicsProvider; import org.labkey.nbri_ehr.demographics.ActiveCasesDemographicsProvider; +import org.labkey.nbri_ehr.demographics.ActiveConceptionsDemographicsProvider; import org.labkey.nbri_ehr.demographics.ActiveFlagsDemographicsProvider; import org.labkey.nbri_ehr.demographics.ActiveTreatmentsDemographicsProvider; import org.labkey.nbri_ehr.demographics.CagematesDemographicsProvider; @@ -135,6 +136,7 @@ protected void doStartupAfterSpringConfig(ModuleContext moduleContext) ehrService.registerDemographicsProvider(new ActiveTreatmentsDemographicsProvider(this)); ehrService.registerDemographicsProvider(new SourceDemographicsProvider(this)); ehrService.registerDemographicsProvider(new NecropsyStatusDemographicsProvider(this)); + ehrService.registerDemographicsProvider(new ActiveConceptionsDemographicsProvider(this)); EHRService.get().registerHistoryDataSource(new AnimalGroupsDataSource(this)); EHRService.get().registerHistoryDataSource(new AnimalGroupsEndDataSource(this)); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehaviorRoundsFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehaviorRoundsFormType.java index b85e71a..e969398 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehaviorRoundsFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehaviorRoundsFormType.java @@ -46,7 +46,7 @@ public NBRIBehaviorRoundsFormType(DataEntryFormContext ctx, Module owner) new NBRIAnimalDetailsFormSection(), new NBRICaseTemplateFormSection("Case Template", "Case Template", "nbri_ehr-casetemplatepanel", Arrays.asList(ClientDependency.supplierFromPath("nbri_ehr/panel/CaseTemplatePanel.js"))), new NBRICasesFormPanelSection("Behavior Case", ctx, true), - new NBRIClinicalObservationsFormSection(true, "cases"), + new NBRIClinicalObservationsFormSection(null, true, "cases"), new NBRITreatmentGivenFormSection(true, "cases") )); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehavioralCasesFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehavioralCasesFormType.java index 3bcc93a..d0bc2c5 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehavioralCasesFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehavioralCasesFormType.java @@ -50,7 +50,7 @@ public NBRIBehavioralCasesFormType(DataEntryFormContext ctx, Module owner) new NBRICaseTemplateFormSection("Case Template", "Case Template", "nbri_ehr-casetemplatepanel", Arrays.asList(ClientDependency.supplierFromPath("nbri_ehr/panel/CaseTemplatePanel.js"))), new NBRICasesFormPanelSection("Behavior Case", ctx, true), new NBRIClinicalRemarksFormPanelSection(true, "cases", "Behavior Assessment", ctx, true), - new NBRIClinicalObservationsFormSection(true, "cases"), + new NBRIClinicalObservationsFormSection(null, true, "cases"), new NBRIObservationOrdersFormSection(null, true, "cases"), new NBRITreatmentGivenFormSection(true, "cases"), new NBRITreatmentOrderFormSection(true, "cases") diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkBehaviorFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkBehaviorFormType.java index 35c87c0..0603876 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkBehaviorFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkBehaviorFormType.java @@ -45,8 +45,8 @@ public NBRIBulkBehaviorFormType(DataEntryFormContext ctx, Module owner) new NBRIClinicalRemarksFormSection("Behavior Assessment", ctx.getContainer().hasPermission(ctx.getUser(), NBRIEHRVetTechPermission.class), ctx.getContainer().hasPermission(ctx.getUser(), EHRVeterinarianPermission.class), ctx.getContainer().hasPermission(ctx.getUser(), AdminPermission.class)), - new NBRIClinicalObservationsFormSection(false, null), - new NBRIObservationOrdersFormSection("NBRI_DAILY_CLINICAL_OBS_ORDERS", false, null), + new NBRIClinicalObservationsFormSection(null, false, null), + new NBRIObservationOrdersFormSection(null, false, null), new NBRITreatmentGivenFormSection(), new NBRITreatmentOrderFormSection() )); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkClinicalFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkClinicalFormType.java index a4317ff..e53b02e 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkClinicalFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkClinicalFormType.java @@ -51,7 +51,7 @@ public NBRIBulkClinicalFormType(DataEntryFormContext ctx, Module owner) ctx.getContainer().hasPermission(ctx.getUser(), EHRVeterinarianPermission.class), ctx.getContainer().hasPermission(ctx.getUser(), AdminPermission.class)), new NBRIWeightFormSection(true, true), - new NBRIClinicalObservationsFormSection(false, null), + new NBRIClinicalObservationsFormSection("NBRI_DAILY_CLINICAL_OBS", false, null), new NBRIObservationOrdersFormSection("NBRI_DAILY_CLINICAL_OBS_ORDERS", false, null), new NBRIProcedureFormSection(), new NBRIProcedureOrderFormSection(), diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRICasesFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRICasesFormType.java index 04c3292..5360f51 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRICasesFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRICasesFormType.java @@ -58,7 +58,7 @@ public NBRICasesFormType(DataEntryFormContext ctx, Module owner) new NBRICasesFormPanelSection("Clinical Case", ctx, false), new NBRIClinicalRemarksFormPanelSection(true, "cases", "Clinical Remarks", ctx, false), new NBRIWeightFormSection(true, false, true, "cases"), - new NBRIClinicalObservationsFormSection(true, "cases"), + new NBRIClinicalObservationsFormSection("NBRI_DAILY_CLINICAL_OBS", true, "cases"), new NBRIObservationOrdersFormSection(null, true, "cases"), new NBRIProcedureFormSection(true, "cases"), new NBRIProcedureOrderFormSection(true, "cases"), diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalObservationsFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalObservationsFormType.java index 9a2094f..609f46c 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalObservationsFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalObservationsFormType.java @@ -37,7 +37,7 @@ public NBRIClinicalObservationsFormType(DataEntryFormContext ctx, Module owner) super(ctx, owner, NAME, NAME, "Clinical", Arrays.asList( new NBRITaskFormSection(), new NBRIAnimalDetailsFormSection(), - new NBRIClinicalObservationsFormSection(false, false), + new NBRIClinicalObservationsFormSection("NBRI_DAILY_CLINICAL_OBS", false), new NBRIWeightFormSection(true, true) )); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalRoundsFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalRoundsFormType.java index 2864256..b3ac23b 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalRoundsFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalRoundsFormType.java @@ -50,7 +50,7 @@ public NBRIClinicalRoundsFormType(DataEntryFormContext ctx, Module owner) new NBRICaseTemplateFormSection("Case Template", "Case Template", "nbri_ehr-casetemplatepanel", Arrays.asList(ClientDependency.supplierFromPath("nbri_ehr/panel/CaseTemplatePanel.js"))), new NBRICasesFormPanelSection("Clinical Case", ctx, false), new NBRIWeightFormSection(true, false, true, "cases"), - new NBRIClinicalObservationsFormSection(true, "cases"), + new NBRIClinicalObservationsFormSection("NBRI_DAILY_CLINICAL_OBS", true, "cases"), new NBRIProcedureFormSection(true, "cases"), new NBRITreatmentGivenFormSection(true, "cases"), new NBRIVitalsFormSection(true, "cases"), diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIArrivalFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIArrivalFormSection.java index 5707656..46d0204 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIArrivalFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIArrivalFormSection.java @@ -55,8 +55,10 @@ protected List getFieldKeys(TableInfo ti) keys.add(indexOf(keys, "project") + 1, FieldKey.fromString("Id/demographics/geographic_origin")); - // the social code sits beside Initial Location - keys.add(indexOf(keys, "cage") + 1, FieldKey.fromString("Id/demographics/socialCode")); + // the social code and generation sit beside Initial Location + keys.addAll(indexOf(keys, "cage") + 1, List.of( + FieldKey.fromString("Id/demographics/socialCode"), + FieldKey.fromString("Id/demographics/generation"))); return keys; } diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIBirthFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIBirthFormSection.java index 8088c16..12907ca 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIBirthFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIBirthFormSection.java @@ -38,6 +38,7 @@ public class NBRIBirthFormSection extends NewAnimalFormSection FieldKey.fromString("Id/demographics/sire"), FieldKey.fromString("cage"), FieldKey.fromString("Id/demographics/socialCode"), + FieldKey.fromString("Id/demographics/generation"), FieldKey.fromString("project"), FieldKey.fromString("birthProtocol"), FieldKey.fromString("groupId"), diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIClinicalObservationsFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIClinicalObservationsFormSection.java index b435c34..a9b95fb 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIClinicalObservationsFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIClinicalObservationsFormSection.java @@ -22,24 +22,23 @@ public class NBRIClinicalObservationsFormSection extends BaseFormSection { public static final String LABEL = "Observations"; - private boolean _autoPopulateDailyObs; + private final String _dailyObsOption; - public NBRIClinicalObservationsFormSection(boolean autoPopulateDailyObs, boolean initCollapsed) + public NBRIClinicalObservationsFormSection(String dailyObsOption, boolean initCollapsed) { super("study", "clinical_observations", LABEL, "ehr-clinicalobservationgridpanel", true, initCollapsed, true); - _autoPopulateDailyObs = autoPopulateDailyObs; + _dailyObsOption = dailyObsOption; addClientDependency(ClientDependency.supplierFromPath("ehr/plugin/ClinicalObservationsCellEditing.js")); addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/data/ClinicalObservationClientStore.js")); addClientDependency(ClientDependency.supplierFromPath("ehr/grid/ClinicalObservationGridPanel.js")); - addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/buttons/clinicalObsGridButton.js")); addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/buttons/addClinicalObsButton.js")); setClientStoreClass("NBRI_EHR.data.ClinicalObservationsClientStore"); } - public NBRIClinicalObservationsFormSection(boolean isChild, String parentQueryName) + public NBRIClinicalObservationsFormSection(String dailyObsOption, boolean isChild, String parentQueryName) { - this(false, true); + this(dailyObsOption, true); if (isChild && null != parentQueryName) { @@ -57,12 +56,9 @@ public List getTbarButtons() { List defaults = super.getTbarButtons(); - if (_autoPopulateDailyObs) + if (_dailyObsOption != null) { - defaults.add("NBRI_AUTO_POPULATE_DAILY_OBS"); - } - else { - defaults.add("NBRI_DAILY_CLINICAL_OBS"); + defaults.add(_dailyObsOption); } return defaults; diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIObservationOrdersFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIObservationOrdersFormSection.java index 9a2171e..82de7de 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIObservationOrdersFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIObservationOrdersFormSection.java @@ -32,7 +32,6 @@ public NBRIObservationOrdersFormSection(String dailyObsOption, boolean initColla _dailyObsOption = dailyObsOption; addClientDependency(ClientDependency.supplierFromPath("ehr/plugin/ClinicalObservationsCellEditing.js")); addClientDependency(ClientDependency.supplierFromPath("ehr/grid/ClinicalObservationGridPanel.js")); - addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/buttons/clinicalObsGridButton.js")); addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/buttons/addClinicalObsButton.js")); setClientStoreClass("NBRI_EHR.data.ObsOrdersClientStore"); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java new file mode 100644 index 0000000..b27b347 --- /dev/null +++ b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.nbri_ehr.demographics; + +import org.labkey.api.data.Sort; +import org.labkey.api.ehr.demographics.AbstractListDemographicsProvider; +import org.labkey.api.module.Module; +import org.labkey.api.query.FieldKey; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +public class ActiveConceptionsDemographicsProvider extends AbstractListDemographicsProvider +{ + public ActiveConceptionsDemographicsProvider(Module module) + { + super(module, "study", "activeConceptions", "activeConceptions"); + // isActive already excludes non-public conceptions, and the query exposes no QCState column for the inherited filter to use + _supportsQCState = false; + } + + @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)); + } + + @Override + protected Collection getFieldKeys() + { + Set keys = new HashSet<>(); + keys.add(FieldKey.fromString("Id")); + keys.add(FieldKey.fromString("ConceptId")); + keys.add(FieldKey.fromString("ConceptDate")); + + return keys; + } + + @Override + protected Sort getSort() + { + return new Sort("-ConceptDate"); + } +} diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/history/DeathDataSource.java b/nbri_ehr/src/org/labkey/nbri_ehr/history/DeathDataSource.java index 1e00fcd..ae3fc77 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/history/DeathDataSource.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/history/DeathDataSource.java @@ -35,7 +35,7 @@ public DeathDataSource(Module module) @Override protected Set getColumnNames() { - return PageFlowUtil.set("Id", "date", "reason/title", "remark"); + return PageFlowUtil.set("Id", "date", "remark"); } @Override @@ -43,16 +43,6 @@ protected String getHtml(Container c, Results rs, boolean redacted) throws SQLEx { StringBuilder sb = new StringBuilder(); - if(rs.hasColumn(FieldKey.fromString("reason/title")) && rs.getObject(FieldKey.fromString("reason/title")) != null) - { - sb.append(safeAppend(rs, "Disposition", "reason/title")); - } - else - { - sb.append("Disposition: Unknown"); - sb.append("\n"); - } - if(rs.hasColumn(FieldKey.fromString("remark")) && rs.getObject(FieldKey.fromString("remark")) != null) sb.append(safeAppend(rs, "Remark", "remark")); 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 d88b672..30724df 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 @@ -80,6 +80,7 @@ public class NBRI_EHRTriggerHelper private User _user; private static final Logger _log = LogManager.getLogger(NBRI_EHRTriggerHelper.class); private final Map _cachedDrugFormulary = new HashMap<>(); + private final Map _cachedObservationTypeCategories = new HashMap<>(); // Maps an originating observation order's taskid to the task its scheduled observations are grouped under, // for the duration of a single save batch (the same helper instance is reused across rows in the batch). @@ -299,6 +300,21 @@ public boolean animalIdExists(String id) return ts.exists(); } + /** + * Null both when the animal has no demographics record and when it has one carrying no generation; birth.js treats + * the two the same, so the caller never needs to tell them apart. + */ + public Integer getGeneration(String id) + { + TableInfo ti = getTableInfo("study", "demographics"); + if (null == ti.getColumn("generation")) + throw new IllegalStateException("The demographics dataset has no 'generation' column. Import the reference study to add it."); + + TableSelector ts = new TableSelector(ti, PageFlowUtil.set("generation"), new SimpleFilter(FieldKey.fromString("Id"), id), null); + + return ts.getObject(Integer.class); + } + public boolean birthExists(String id) { TableInfo ti = getTableInfo("study", "birth"); @@ -583,11 +599,10 @@ public void sendDeathNotification(final String animalId) //get death info TableInfo deaths = getTableInfo("study", "deathNotification"); - TableSelector deathsTs = new TableSelector(deaths, PageFlowUtil.set("Id", "date", "taskid", "performedBy", "reason"), new SimpleFilter(FieldKey.fromString("Id"), animalId), null); + TableSelector deathsTs = new TableSelector(deaths, PageFlowUtil.set("Id", "date", "taskid", "performedBy"), new SimpleFilter(FieldKey.fromString("Id"), animalId), null); final Mutable deathDate = new MutableObject<>(); final Mutable taskId = new MutableObject<>(); final Mutable performedBy = new MutableObject<>(); - final Mutable disposition = new MutableObject<>(); deathsTs.forEach(rs -> { if (rs.getString("date") != null) { @@ -595,7 +610,6 @@ public void sendDeathNotification(final String animalId) deathDate.setValue(date); taskId.setValue(rs.getString("taskid")); performedBy.setValue(rs.getString("performedBy")); - disposition.setValue(rs.getString("reason")); } }); @@ -608,8 +622,7 @@ public void sendDeathNotification(final String animalId) return; } html.append("Animal '").append(PageFlowUtil.filter(animalId)).append("' has been declared dead on '").append(_dateFormat.format(deathDate.get())).append("'.
"); - html.append("Performed By: ").append(PageFlowUtil.filter(performedBy.get())).append("
"); - html.append("Disposition: ").append(PageFlowUtil.filter(disposition.get())).append("

"); + html.append("Performed By: ").append(PageFlowUtil.filter(performedBy.get())).append("

"); //append animal details appendAnimalDetails(html, animalId, container); @@ -828,6 +841,18 @@ 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 + public String getConceptionDam(String conceptId) + { + if (conceptId == null) + return null; + + TableInfo ti = getTableInfo("nbri_ehr", "Conception"); + SimpleFilter filter = new SimpleFilter(FieldKey.fromString("ConceptId"), conceptId); + + return new TableSelector(ti, Collections.singleton("Dam"), filter, null).getObject(String.class); + } + public boolean canCloseCase() { return _container.hasPermission(_user, EHRVeterinarianPermission.class); @@ -924,6 +949,26 @@ public void ensureDailyClinicalObservationOrders(String id, String caseid, final } } + /** + * Returns the category of an observation type from ehr.observation_types, or null when the type has no + * category or is not found. Cached for the life of the save batch. + */ + public String getObservationTypeCategory(String observationType) + { + if (observationType == null) + return null; + + if (!_cachedObservationTypeCategories.containsKey(observationType)) + { + TableInfo ti = getTableInfo("ehr", "observation_types"); + SimpleFilter filter = new SimpleFilter(FieldKey.fromString("value"), observationType); + List categories = new TableSelector(ti, Collections.singleton("category"), filter, null).getArrayList(String.class); + _cachedObservationTypeCategories.put(observationType, categories.isEmpty() ? null : categories.get(0)); + } + + return _cachedObservationTypeCategories.get(observationType); + } + // This helper function propagates clinical observations through clinical cases public Map handleScheduledObservations(Map row, String qcstate, String orderTasks) throws SQLException, BatchValidationException, QueryUpdateServiceException, DuplicateKeyException { 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 7f4406b..ca8a01a 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 @@ -62,9 +62,11 @@ import org.labkey.nbri_ehr.dataentry.form.NBRIClinicalObservationsFormType; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.HashSet; +import java.util.List; import java.util.Set; public class NBRI_EHRCustomizer extends AbstractTableCustomizer @@ -644,6 +646,10 @@ public void doTableSpecificCustomizations(AbstractTableInfo ti) { addIsActiveForProject(ti, EHRService.EndingOption.activeAfterMidnightTonight); } + if (matches(ti, "nbri_ehr", "Conception")) + { + addIsActiveForConception(ti); + } if (matches(ti, "study", "protocolAssignment")) { EHRService.get().addIsActiveCol(ti, false, EHRService.EndingOption.activeAfterMidnightTonight, EHRService.EndingOption.activeAfterMidnightTonight); @@ -685,6 +691,64 @@ 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) + return; + + String birthTable = getDatasetStorageTableName(ehrContainer, "birth"); + String pregnancyTable = getDatasetStorageTableName(ehrContainer, "pregnancy"); + if (birthTable == null || pregnancyTable == 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)"); + + 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); + } + + // 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) + { + return "NOT EXISTS (SELECT 1 FROM core.datastates ds WHERE ds.rowid = " + tableAlias + ".qcstate AND ds.publicdata = " + isFalse + ")"; + } + + private String getDatasetStorageTableName(Container c, String datasetName) + { + StudyService studyService = StudyService.get(); + if (studyService == null) + return null; + + Dataset dataset = studyService.getDataset(c, studyService.getDatasetIdByName(c, datasetName)); + return dataset != null && dataset.getDomain() != null ? dataset.getDomain().getStorageTableName() : null; + } + public void doSharedCustomization(AbstractTableInfo ti) { for (var col : ti.getMutableColumns()) 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 0303d58..d8606eb 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 @@ -110,6 +110,9 @@ public class NBRI_EHRTest extends AbstractGenericEHRTest // Dedicated animal for testScheduledObservationTaskGrouping; provisioned (alive, housed, assigned) in // createTestSubjects so the clinical case form raises no warnings that would keep the validation banner up. private static final String taskGroupAnimalId = "TESTGRP9090"; + // Dedicated animal for testObservationTypeDerivedFromCategory; provisioned the same way so the Observations + // form can be submitted final in one step. + private static final String obsTypeAnimalId = "TESTOBSTYPE9191"; // Rooms are keyed by building and name, so every room fixture needs a building to hang off of. // 'buildings' derives its key from the description, and 'SPF' is one of the areas seeded with the ehr_lookups schema. @@ -574,6 +577,32 @@ protected void createTestSubjects() throws Exception getApiHelper().deleteAllRecords("study", "Assignment", new Filter("Id", taskGroupAnimalId)); getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + // Fully provision the observation-type test animal for the same reason. + log("Creating observation type test subject"); + fields = new String[]{"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby"}; + data = new Object[][]{ + {obsTypeAnimalId, "MMU", (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "demographics", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "demographics", new Filter("Id", obsTypeAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + + fields = new String[]{"Id", "date", "enddate", "room", "cage", "performedby"}; + data = new Object[][]{ + {obsTypeAnimalId, pastDate1, null, getRooms()[0], CAGE_IN_R1, 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "Housing", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "Housing", new Filter("Id", obsTypeAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + + fields = new String[]{"Id", "date", "enddate", "project", "performedby"}; + data = new Object[][]{ + {obsTypeAnimalId, pastDate1, null, PROJECTS[0], 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "Assignment", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "Assignment", new Filter("Id", obsTypeAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + primeCaches(); } @@ -666,6 +695,12 @@ public void testArrivalForm() throws IOException, CommandException waitForFormError("The field: Social Code is required"); arrivals.setGridCell(1, "Id/demographics/socialCode", socialCode); + log("Verifying Generation is seeded to 0 and is required"); + assertEquals("An arriving animal should start at generation 0", "0", String.valueOf(arrivals.getFieldValue(1, "Id/demographics/generation"))); + arrivals.setGridCellJS(1, "Id/demographics/generation", null); + waitForFormError("The field: Generation is required"); + arrivals.setGridCellJS(1, "Id/demographics/generation", 0); + // the animal's opening project, protocol and group are entered on the arrival row itself; the trigger script // opens the matching assignment record for each one arrivals.setGridCell(1, "project", "640991"); @@ -724,6 +759,8 @@ public void testArrivalForm() throws IOException, CommandException table.setFilter("Id", "Equals", arrivedAnimal); Assert.assertEquals("Social code entered on the arrival form did not reach demographics", Arrays.asList(socialCode), table.getRowDataAsText(0, "socialCode")); + Assert.assertEquals("Generation seeded by the arrival form did not reach demographics", + Arrays.asList("0"), table.getRowDataAsText(0, "generation")); log("Verifying the birth date reached demographics and agrees with the birth record"); String arrivalBirthDay = now.minusDays(7).format(_dateFormat); @@ -744,6 +781,7 @@ public void testBirthForm() throws Exception String damSpecies = "Brown-Tufted Capuchin"; String conceptId = "TESTCONCEPT1"; String breedingType = "Time-Mated"; + int damGeneration = 2; // demographics.socialCode holds an ehr_lookups.social_code code; the grids display its title String socialCode = "Mother-rearing (for indoors)"; // the group is an ehr_lookups.breeding_type code, carried to animal_group_members; the grids display its title @@ -751,13 +789,16 @@ public void testBirthForm() throws Exception LocalDateTime now = LocalDateTime.now(); log("Creating the dam and sire of the conception"); - createBreedingPair(damId, sireId, damSpeciesCode); + 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)); 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")); + gotoEnterData(); waitAndClickAndWait(Locator.linkWithText("Birth")); lockForm(); @@ -773,6 +814,8 @@ public void testBirthForm() throws Exception assertEquals("Dam was not copied from the conception", damId, births.getFieldValue(1, "Id/demographics/dam")); assertEquals("Sire was not copied from the conception", sireId, births.getFieldValue(1, "Id/demographics/sire")); assertEquals("Species was not copied from the dam of the conception", damSpeciesCode, births.getFieldValue(1, "Id/demographics/species")); + assertEquals("Generation was not derived from the dam of the conception", String.valueOf(damGeneration + 1), + String.valueOf(births.getFieldValue(1, "Id/demographics/generation"))); log("Verifying Conception Id is required"); births.setGridCellJS(1, "conceptId", null); @@ -791,6 +834,11 @@ public void testBirthForm() throws Exception waitForFormError("The field: Social Code is required"); births.setGridCell(1, "Id/demographics/socialCode", socialCode); + log("Verifying Generation is required"); + births.setGridCellJS(1, "Id/demographics/generation", null); + waitForFormError("The field: Generation is required"); + births.setGridCellJS(1, "Id/demographics/generation", damGeneration + 1); + // the animal's opening project, protocol and group are entered on the birth row itself; the trigger script // opens the matching assignment record for each one births.setGridCell(1, "project", "795644"); @@ -829,6 +877,8 @@ public void testBirthForm() throws Exception Assert.assertEquals("Invalid demographics record", Arrays.asList(damSpecies), table.getRowDataAsText(0, "species")); Assert.assertEquals("Social code entered on the birth form did not reach demographics", Arrays.asList(socialCode), table.getRowDataAsText(0, "socialCode")); + Assert.assertEquals("Generation derived from the dam did not reach demographics", + Arrays.asList(String.valueOf(damGeneration + 1)), table.getRowDataAsText(0, "generation")); goToSchemaBrowser(); table = viewQueryData("study", "assignment"); @@ -865,6 +915,11 @@ public void testBirthForm() throws Exception 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")); + Assert.assertEquals("A conception claimed by a birth should not be active", + Arrays.asList("false"), report.getRowDataAsText(0, "isActive")); + + log("Verifying the birth cleared the dam's pregnancy"); + assertEquals("Animal Details still reports a conception a birth has closed", "No", getSnapshotFieldValue(damId, "Pregnant")); } @Test @@ -901,9 +956,11 @@ public void testDuplicateConceptionRejected() throws Exception log("Entering two births that both claim the first conception"); startWithConception(births, firstConcept, 1); - fillBirthRow(births, 1, firstAnimal, now.minusDays(1), socialCode, animalGroup); + assertEquals("A dam with no generation of her own should leave the birth at generation 1", "1", + String.valueOf(births.getFieldValue(1, "Id/demographics/generation"))); + fillBirthRow(births, 1, firstAnimal, now.minusDays(1), socialCode, animalGroup, CAGE_IN_R2); startWithConception(births, firstConcept, 2); - fillBirthRow(births, 2, secondAnimal, now.minusDays(1), socialCode, animalGroup); + fillBirthRow(births, 2, secondAnimal, now.minusDays(1), socialCode, animalGroup, CAGE_IN_R3); // Live validation only sends the row that just changed, so the rows of one form entry first reach the // server together on submit. A rule that compares them therefore reports by refusing the save rather than @@ -946,6 +1003,9 @@ public void testConceptionPickedFromGridCell() throws Exception // different species on each pair, so the copy from the newly picked dam is visible String firstSpeciesCode = "CAP"; String secondSpeciesCode = "MMU"; + // different generations on each dam, so the re-derivation from the newly picked dam is visible + int firstDamGeneration = 2; + int secondDamGeneration = 5; String firstConcept = "TESTCONCEPT6"; String secondConcept = "TESTCONCEPT7"; String socialCode = "Mother-rearing (for indoors)"; @@ -953,8 +1013,8 @@ public void testConceptionPickedFromGridCell() throws Exception LocalDateTime now = LocalDateTime.now(); log("Creating a breeding pair and a conception for each"); - createBreedingPair(firstDam, firstSire, firstSpeciesCode); - createBreedingPair(secondDam, secondSire, secondSpeciesCode); + 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)); @@ -967,7 +1027,7 @@ public void testConceptionPickedFromGridCell() throws Exception Ext4GridRef births = _helper.getExt4GridForFormSection("Births"); startWithConception(births, firstConcept, 1); - fillBirthRow(births, 1, bornAnimal, now.minusDays(1), socialCode, animalGroup); + fillBirthRow(births, 1, bornAnimal, now.minusDays(1), socialCode, animalGroup, CAGE_IN_R1); births.setGridCell(1, "breedingType", "Time-Mated"); // the codes behind these lookups are not spelled out in the test, so remember what the row carries and @@ -998,6 +1058,8 @@ public void testConceptionPickedFromGridCell() throws Exception assertEquals("Dam was not replaced from the picked conception", secondDam, births.getFieldValue(1, "Id/demographics/dam")); assertEquals("Sire was not replaced from the picked conception", secondSire, births.getFieldValue(1, "Id/demographics/sire")); assertEquals("Species was not replaced from the dam of the picked conception", secondSpeciesCode, births.getFieldValue(1, "Id/demographics/species")); + assertEquals("Generation was not re-derived from the dam of the picked conception", String.valueOf(secondDamGeneration + 1), + String.valueOf(births.getFieldValue(1, "Id/demographics/generation"))); log("Verifying nothing else on the row was touched"); assertEquals("Animal Id should have been left alone", bornAnimal, births.getFieldValue(1, "Id")); @@ -1034,6 +1096,7 @@ public void testPregnancyForm() throws IOException, CommandException String conceptId = "TESTCONCEPT2"; // a non-live outcome, so ConceptionsByDam reports it rather than falling through to 'Live Birth' String result = "Fetal Death"; + String deliveryMode = "Vaginal"; LocalDateTime now = LocalDateTime.now(); log("Creating conception record"); @@ -1051,6 +1114,7 @@ public void testPregnancyForm() throws IOException, CommandException outcomes.setGridCell(1, "Id", animalId); outcomes.setGridCell(1, "result", result); outcomes.setGridCell(1, "conceptId", conceptId); + outcomes.setGridCell(1, "type", deliveryMode); submitForm("Submit Final", "Finalize"); goToSchemaBrowser(); @@ -1059,6 +1123,7 @@ public void testPregnancyForm() throws IOException, CommandException Assert.assertEquals("Invalid Pregnancy Outcome record", Arrays.asList(animalId), table.getRowDataAsText(0, "Id")); Assert.assertEquals("Invalid Pregnancy Outcome record", Arrays.asList(result), table.getRowDataAsText(0, "result")); Assert.assertEquals("Invalid Pregnancy Outcome record", Arrays.asList(conceptId), table.getRowDataAsText(0, "conceptId")); + Assert.assertEquals("Invalid Pregnancy Outcome record", Arrays.asList(deliveryMode), table.getRowDataAsText(0, "type")); log("Verifying conception outcome in ConceptionsByDam"); goToSchemaBrowser(); @@ -1066,6 +1131,13 @@ public void testPregnancyForm() throws IOException, CommandException 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", + Arrays.asList("false"), report.getRowDataAsText(0, "isActive")); + + log("Verifying the pregnancy outcome cleared the dam's pregnancy"); + // this dam carries other conceptions from sibling tests, so assert only that this one is gone + Assert.assertFalse("Animal Details still reports a conception a pregnancy outcome has closed", + getSnapshotFieldValue(animalId, "Pregnant").contains(conceptId)); } @Test @@ -1110,6 +1182,30 @@ public void testConceptionForm() 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", + Arrays.asList("true"), report.getRowDataAsText(0, "isActive")); + + log("Verifying the dam's Animal Details links to the open conception"); + // this dam carries other conceptions from sibling tests, so assert only that this one is listed + Assert.assertTrue("Animal Details did not report the open conception", + getSnapshotFieldValue(damId, "Pregnant").contains(conceptId)); + } + + /** + * Reads one field from the Animal Details snapshot panel, which renders Ext4 displayfields rather than a grid, so + * there is no page object to read through. The value arrives from the demographics cache after the page settles, + * so an empty field means not-yet-loaded rather than no value. + */ + private String getSnapshotFieldValue(String animalId, String fieldLabel) + { + ParticipantViewPage.beginAt(this, animalId); + Locator field = Locator.xpath("//*[contains(@class,'x4-form-item')][.//label[starts-with(normalize-space(.),'" + + fieldLabel + "')]]//div[contains(@class,'x4-form-display-field')]"); + waitForElement(field); + waitFor(() -> !field.findElement(getDriver()).getText().trim().isEmpty(), + "Animal Details did not populate the " + fieldLabel + " field", WAIT_FOR_JAVASCRIPT); + + return field.findElement(getDriver()).getText().trim(); } @Test @@ -1314,7 +1410,13 @@ public void testScheduledObservationTaskGrouping() Map entriesPerCategory = new HashMap<>(); for (Map row : getClinicalObservations(animalId)) + { entriesPerCategory.merge(String.valueOf(row.get("category")), 1, Integer::sum); + // A scheduled observation takes its type from the originating order, which the daily clinical + // observation orders create as Clinical. + Assert.assertEquals("Scheduled observation for category " + row.get("category") + " should be Clinical", + "Clinical", String.valueOf(row.get("type"))); + } Assert.assertEquals("Expected the six daily observation categories", NBRI_DAILY_OBS_VALUES.size(), entriesPerCategory.size()); entriesPerCategory.forEach((category, count) -> Assert.assertEquals("Expected two entries (one per matching order) for category " + category, Integer.valueOf(2), count)); @@ -1406,6 +1508,56 @@ private int countObservationsForTask(String taskId) return executeSelectRowCommand("study", "clinical_observations", ContainerFilter.Current, "/" + getContainerPath(), List.of(new Filter("taskid", taskId))).getRowCount().intValue(); } + // Two ehr.observation_types values on either side of the derivation: the first has no category, the second + // is categorized as Behavior. Both use a free-text Observation/Score editor, so neither depends on an + // ehr_lookups value list being populated. + private static final String UNCATEGORIZED_OBS_TYPE = "Mass"; + private static final String BEHAVIOR_OBS_TYPE = "General Behavior Observation"; + + @Test + public void testObservationTypeDerivedFromCategory() + { + String animalId = obsTypeAnimalId; + + // The Observations form offers every observation type, so it cannot set the observation's type up + // front; the trigger script derives it from the selected type's category. A type categorized as + // Behavior must be stored as a Behavior observation and everything else as Clinical, otherwise the + // entry drops out of the behavior views (study.behaviorObservations filters on type = 'Behavior'). + log("Entering an uncategorized and a Behavior-categorized observation type on the Observations form"); + gotoEnterData(); + waitAndClickAndWait(Locator.linkWithText("Observations")); + + Ext4GridRef observations = _helper.getExt4GridForFormSection("Observations"); + addObservationRow(observations, animalId, UNCATEGORIZED_OBS_TYPE, "3 cm mass on left arm"); + addObservationRow(observations, animalId, BEHAVIOR_OBS_TYPE, "Pacing observed"); + submitForm("Submit Final", "Finalize"); + + Map typeByCategory = new HashMap<>(); + for (Map row : getClinicalObservations(animalId)) + typeByCategory.put(String.valueOf(row.get("category")), String.valueOf(row.get("type"))); + + Assert.assertEquals("Expected exactly the two entered observations for " + animalId, + Set.of(UNCATEGORIZED_OBS_TYPE, BEHAVIOR_OBS_TYPE), typeByCategory.keySet()); + Assert.assertEquals("An uncategorized observation type should be stored as a Clinical observation", + "Clinical", typeByCategory.get(UNCATEGORIZED_OBS_TYPE)); + Assert.assertEquals("A Behavior-categorized observation type should be stored as a Behavior observation", + "Behavior", typeByCategory.get(BEHAVIOR_OBS_TYPE)); + } + + // Appends a row to an Observations grid and fills in the fields the trigger script needs to accept it: an + // animal, an observation type (the grid's "category"), and an Observation/Score plus remark, since an entry + // with neither raises a WARN that would disable Submit Final. The row index is read back from the grid + // rather than assumed, so this works whether or not the form starts with rows of its own. + private void addObservationRow(Ext4GridRef observations, String animalId, String category, String observation) + { + _helper.addRecordToGrid(observations); + int row = observations.getRowCount(); + observations.setGridCell(row, "Id", animalId); + observations.setGridCell(row, "category", category); + observations.setGridCell(row, "observation", observation); + observations.setGridCellJS(row, "remark", "remark for " + category); + } + @Test public void testObservationBulkEdit() { @@ -1533,7 +1685,7 @@ public void createSubjectsForDeathForm() throws IOException, CommandException log("Marking an animal dead"); InsertRowsCommand deaths = new InsertRowsCommand("study", "deaths"); - deaths.addRow(Map.of("Id", deadAnimalId, "date", LocalDateTime.now().minusDays(10), "reason", "4", "performedby", 1004)); + deaths.addRow(Map.of("Id", deadAnimalId, "date", LocalDateTime.now().minusDays(10), "performedby", 1004)); deaths.execute(getApiHelper().getConnection(), getContainerPath()); log("Marking an animal departed"); @@ -1575,7 +1727,6 @@ public void testDeathNecropsyForm() throws IOException, CommandException setFormElement(Locator.name("Id"), aliveAnimalId); _ext4Helper.selectComboBoxItem("Death Type:", "Spontaneous/Normal"); - _ext4Helper.selectComboBoxItem("Disposition:", "Euthaniasia (project)"); waitForElement(Locator.name("deathWeight")); setFormElement(Locator.name("deathWeight"), "23"); Assert.assertFalse(isElementPresent(Locator.linkWithText("Submit Necropsy for Review"))); @@ -1585,8 +1736,8 @@ public void testDeathNecropsyForm() throws IOException, CommandException log("Verify a second death insert is rejected with a validation error, not a unique constraint violation"); SimplePostCommand duplicateDeath = getApiHelper().prepareInsertCommand("study", "deaths", "lsid", - new String[]{"Id", "date", "reason", "performedby"}, - new Object[][]{{aliveAnimalId, LocalDateTime.now(), "4", 1004}}); + new String[]{"Id", "date", "performedby"}, + new Object[][]{{aliveAnimalId, LocalDateTime.now(), 1004}}); CommandException duplicateError = getApiHelper().doSaveRowsExpectingError(DATA_ADMIN.getEmail(), duplicateDeath, getExtraContext()); Map> duplicateErrors = getApiHelper().extractErrors(duplicateError.getProperties()); Assert.assertTrue("Expected duplicate death validation error, got: " + duplicateErrors, @@ -1729,7 +1880,7 @@ public void testDeathDeleteRestoresDepartedStatus() throws Exception // the death has to be recorded before the departure: the deaths trigger rejects an animal that has shipped log("Recording the death"); InsertRowsCommand deaths = new InsertRowsCommand("study", "deaths"); - deaths.addRow(Map.of("Id", animalId, "date", now.minusDays(10), "reason", "4", "QCStateLabel", "Completed", "performedby", 1004)); + deaths.addRow(Map.of("Id", animalId, "date", now.minusDays(10), "QCStateLabel", "Completed", "performedby", 1004)); deaths.execute(getApiHelper().getConnection(), getContainerPath()); assertEquals("Demographics death date does not match the death record", @@ -2235,12 +2386,14 @@ private void startWithConception(Ext4GridRef births, String conceptId, int expec } // Fills in everything a birth row needs beyond what the conception supplies, so the form can be submitted. - // Birth Location is left blank on purpose: it is optional, and skipping it keeps housing out of these tests. + // Generation is not set here: the conception supplies it from the dam. Each row takes its own cage so that a + // co-housing or capacity rule can never be what fails these tests. private void fillBirthRow(Ext4GridRef births, int rowIdx, String animalId, LocalDateTime birthDate, - String socialCode, String animalGroup) + String socialCode, String animalGroup, String cage) { births.setGridCellJS(rowIdx, "date", birthDate.format(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT_STRING))); births.setGridCell(rowIdx, "Id", animalId); + births.setGridCell(rowIdx, "cage", cage); births.setGridCell(rowIdx, "Id/demographics/gender", "Female"); births.setGridCell(rowIdx, "Id/demographics/socialCode", socialCode); births.setGridCell(rowIdx, "project", "795644"); @@ -2250,10 +2403,16 @@ private void fillBirthRow(Ext4GridRef births, int rowIdx, String animalId, Local private void createBreedingPair(String damId, String sireId, String species) throws Exception { - String[] fields = new String[]{"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby"}; + createBreedingPair(damId, sireId, species, null); + } + + // A null damGeneration leaves the dam with no generation of her own, which is what makes her offspring generation 1. + private void createBreedingPair(String damId, String sireId, String species, Integer damGeneration) throws Exception + { + String[] fields = new String[]{"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby", "generation"}; Object[][] data = new Object[][]{ - {damId, species, (new Date()).toString(), getFemale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004}, - {sireId, species, (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004} + {damId, species, (new Date()).toString(), getFemale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004, damGeneration}, + {sireId, species, (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004, null} }; SimplePostCommand insertCommand = getApiHelper().prepareInsertCommand("study", "demographics", "lsid", fields, data); getApiHelper().deleteAllRecords("study", "demographics", new Filter("Id", damId + ";" + sireId, Filter.Operator.IN)); @@ -2265,7 +2424,8 @@ private void createBreedingPair(String damId, String sireId, String species) thr private void verifyBirthColumnOrder(Ext4GridRef births) { List expectedOrder = List.of("Id", "date", "conceptId", "Id/demographics/species", "Id/demographics/gender", - "Id/demographics/dam", "Id/demographics/sire", "cage", "Id/demographics/socialCode", "project", + "Id/demographics/dam", "Id/demographics/sire", "cage", "Id/demographics/socialCode", + "Id/demographics/generation", "project", "birthProtocol", "groupId", "type", "breedingType", "remark", "performedby"); int previousIdx = 0;