Skip to content

Commit 866ccde

Browse files
Merge 26.7 to develop
2 parents 76f4d4f + f2a3299 commit 866ccde

5 files changed

Lines changed: 262 additions & 40 deletions

File tree

onprc_billing/src/org/labkey/onprc_billing/ONPRC_BillingController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ public ModelAndView getConfirmView(QueryForm form, BindException errors) throws
162162
Set<String> ids = DataRegionSelection.getSelected(form.getViewContext(), true);
163163

164164
StringBuilder msg = new StringBuilder("You have selected " + ids.size() + " billing runs to delete. This will also delete: <p>");
165-
for (String m : ONPRC_BillingManager.get().deleteBillingRuns(getUser(), ids, true))
165+
for (String m : ONPRC_BillingManager.get().deleteBillingRuns(getUser(), getContainer(), ids, true))
166166
{
167167
msg.append(m).append("<br>");
168168
}
@@ -176,7 +176,7 @@ public ModelAndView getConfirmView(QueryForm form, BindException errors) throws
176176
public boolean handlePost(QueryForm form, BindException errors) throws Exception
177177
{
178178
Set<String> ids = DataRegionSelection.getSelected(form.getViewContext(), true);
179-
ONPRC_BillingManager.get().deleteBillingRuns(getUser(), ids, false);
179+
ONPRC_BillingManager.get().deleteBillingRuns(getUser(), getContainer(), ids, false);
180180

181181
return true;
182182
}

onprc_billing/src/org/labkey/onprc_billing/ONPRC_BillingManager.java

Lines changed: 196 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
*/
1616
package org.labkey.onprc_billing;
1717

18+
import org.junit.After;
19+
import org.junit.Assert;
20+
import org.junit.Before;
21+
import org.junit.Test;
1822
import org.labkey.api.collections.CaseInsensitiveHashMap;
1923
import org.labkey.api.data.CompareType;
2024
import org.labkey.api.data.Container;
@@ -31,12 +35,19 @@
3135
import org.labkey.api.query.FieldKey;
3236
import org.labkey.api.query.Queryable;
3337
import org.labkey.api.security.User;
38+
import org.labkey.api.util.GUID;
39+
import org.labkey.api.util.JunitUtil;
40+
import org.labkey.api.util.TestContext;
3441

3542
import java.util.ArrayList;
43+
import java.util.Arrays;
3644
import java.util.Collection;
3745
import java.util.Collections;
46+
import java.util.Date;
47+
import java.util.HashSet;
3848
import java.util.List;
3949
import java.util.Map;
50+
import java.util.Set;
4051

4152
/**
4253
* User: bimber
@@ -71,16 +82,28 @@ public static ONPRC_BillingManager get()
7182
return _instance;
7283
}
7384

74-
public List<String> deleteBillingRuns(User user, Collection<String> pks, boolean testOnly)
85+
public List<String> deleteBillingRuns(User user, Container container, Collection<String> pks, boolean testOnly)
7586
{
7687
TableInfo invoiceRuns = ONPRC_BillingSchema.getInstance().getSchema().getTable(ONPRC_BillingSchema.TABLE_INVOICE_RUNS);
7788
TableInfo invoicedItems = ONPRC_BillingSchema.getInstance().getSchema().getTable(ONPRC_BillingSchema.TABLE_INVOICED_ITEMS);
7889
TableInfo miscCharges = ONPRC_BillingSchema.getInstance().getSchema().getTable(ONPRC_BillingSchema.TABLE_MISC_CHARGES);
7990

8091
//create filters
81-
SimpleFilter invoiceRunFilter = new SimpleFilter(FieldKey.fromString("invoiceId"), pks, CompareType.IN);
92+
SimpleFilter invoiceRunsFilter = createContainerScopedInFilter(container, "objectid", pks);
93+
Set<String> invoiceRunIds = getInvoiceRunIds(invoiceRuns, invoiceRunsFilter);
94+
if (invoiceRunIds.isEmpty())
95+
{
96+
List<String> ret = new ArrayList<>();
97+
if (testOnly)
98+
{
99+
ret.add("0 records from invoiced items");
100+
ret.add("0 records from misc charges will be removed from the deleted invoice, which means they will be picked up by the next billing period. They are not deleted.");
101+
}
102+
return ret;
103+
}
82104

83-
SimpleFilter miscChargesFilter = new SimpleFilter(FieldKey.fromString("invoiceId"), pks, CompareType.IN);
105+
SimpleFilter invoiceRunFilter = createContainerScopedInFilter(container, "invoiceId", invoiceRunIds);
106+
SimpleFilter miscChargesFilter = createMiscChargesFilter(invoiceRunIds);
84107

85108
//perform the work
86109
List<String> ret = new ArrayList<>();
@@ -96,7 +119,7 @@ public List<String> deleteBillingRuns(User user, Collection<String> pks, boolean
96119
{
97120
try (DbScope.Transaction transaction = ExperimentService.get().ensureTransaction())
98121
{
99-
long deleted1 = Table.delete(invoicedItems, invoiceRunFilter);
122+
Table.delete(invoicedItems, invoiceRunFilter);
100123

101124
TableSelector tsMiscCharges2 = new TableSelector(miscCharges, Collections.singleton("objectid"), miscChargesFilter, null);
102125
String[] miscChargesIds = tsMiscCharges2.getArray(String.class);
@@ -107,7 +130,7 @@ public List<String> deleteBillingRuns(User user, Collection<String> pks, boolean
107130
Table.update(user, miscCharges, map, objectid);
108131
}
109132

110-
long deleted3 = Table.delete(invoiceRuns, new SimpleFilter(FieldKey.fromString("objectid"), pks, CompareType.IN));
133+
Table.delete(invoiceRuns, invoiceRunsFilter);
111134

112135
transaction.commit();
113136
}
@@ -116,6 +139,33 @@ public List<String> deleteBillingRuns(User user, Collection<String> pks, boolean
116139
return ret;
117140
}
118141

142+
private SimpleFilter createContainerScopedInFilter(Container container, String columnName, Collection<String> values)
143+
{
144+
return SimpleFilter.createContainerFilter(container).addInClause(FieldKey.fromString(columnName), values);
145+
}
146+
147+
private Set<String> getInvoiceRunIds(TableInfo invoiceRuns, SimpleFilter objectIdFilter)
148+
{
149+
TableSelector tsInvoiceRuns = new TableSelector(invoiceRuns, Collections.singleton("objectid"), objectIdFilter, null);
150+
String[] invoiceRunIds = tsInvoiceRuns.getArray(String.class);
151+
return new HashSet<>(Arrays.asList(invoiceRunIds));
152+
}
153+
154+
private SimpleFilter createMiscChargesFilter(Collection<String> invoiceRunIds)
155+
{
156+
// Intentionally NOT container-scoped. Source miscCharges records can live in different containers
157+
// (the billing/finance container, the configured EHR study container, or other satellite containers that
158+
// feed charges into a billing run), so there is no reliable, complete set of "source" containers to filter on.
159+
//
160+
// This is not a cross-container security issue: invoiceRunIds has already been narrowed by getInvoiceRunIds()
161+
// to the run ids that actually exist in the requesting container (see the container-scoped objectIdFilter).
162+
// A forged or out-of-container run id never reaches this filter, so matching miscCharges solely by
163+
// invoiceId only ever touches charges belonging to runs the caller is already authorized to delete.
164+
SimpleFilter filter = new SimpleFilter();
165+
filter.addInClause(FieldKey.fromString("invoiceId"), invoiceRunIds);
166+
return filter;
167+
}
168+
119169
public Container getBillingContainer(Container c)
120170
{
121171
Module billing = ModuleLoader.getInstance().getModule(ONPRC_BillingModule.NAME);
@@ -139,4 +189,145 @@ public Container getSLADataFolder(Container c)
139189
return ContainerManager.getForPath(path);
140190

141191
}
192+
193+
public static class TestCase extends Assert
194+
{
195+
private static final String FOLDER_A = "ONPRCBillingDeleteTestA";
196+
private static final String FOLDER_B = "ONPRCBillingDeleteTestB";
197+
private static final String FOLDER_SATELLITE = "ONPRCBillingDeleteTestSatellite";
198+
199+
private User _user;
200+
private Container _containerA;
201+
private Container _containerB;
202+
private Container _containerSatellite;
203+
private String _runIdA;
204+
private String _runIdB;
205+
206+
@Before
207+
public void setUp()
208+
{
209+
_user = TestContext.get().getUser();
210+
deleteTestFolders();
211+
212+
Container junit = JunitUtil.getTestContainer();
213+
_containerA = createBillingFolder(junit, FOLDER_A);
214+
_containerB = createBillingFolder(junit, FOLDER_B);
215+
_containerSatellite = createBillingFolder(junit, FOLDER_SATELLITE);
216+
217+
_runIdA = insertBillingRun(_containerA);
218+
_runIdB = insertBillingRun(_containerB);
219+
}
220+
221+
@After
222+
public void tearDown()
223+
{
224+
deleteTestFolders();
225+
}
226+
227+
@Test
228+
public void testDeleteBillingRunsIsContainerScoped()
229+
{
230+
ONPRC_BillingManager manager = ONPRC_BillingManager.get();
231+
ONPRC_BillingSchema schema = ONPRC_BillingSchema.getInstance();
232+
233+
// A testOnly preview issued from container A targeting container B's run must not see container B's rows.
234+
for (String summary : manager.deleteBillingRuns(_user, _containerA, List.of(_runIdB), true))
235+
assertTrue("Preview from another container should count 0 rows, but got: " + summary, summary.startsWith("0 "));
236+
237+
// An actual delete issued from container A targeting container B's run must leave container B untouched.
238+
manager.deleteBillingRuns(_user, _containerA, List.of(_runIdB), false);
239+
assertEquals("invoiceRuns row in container B should survive a delete issued from container A", 1, containerRowCount(getTable(schema, ONPRC_BillingSchema.TABLE_INVOICE_RUNS), _containerB));
240+
assertEquals("invoicedItems row in container B should survive a delete issued from container A", 1, containerRowCount(getTable(schema, ONPRC_BillingSchema.TABLE_INVOICED_ITEMS), _containerB));
241+
assertEquals("miscCharges row in container B should still reference its invoice", 1, miscChargesWithInvoiceCount(_containerB));
242+
243+
// Satellite source data: miscCharges can live in a container that is neither the requesting/finance
244+
// container nor the run's own container. The delete is keyed off the authorized run id, so these rows are
245+
// still previewed and detached.
246+
String runIdWithSatelliteMiscCharge = insertBillingRun(_containerA, _containerSatellite);
247+
List<String> satellitePreview = manager.deleteBillingRuns(_user, _containerA, List.of(runIdWithSatelliteMiscCharge), true);
248+
assertTrue("Preview should count miscCharges rows in an unrelated source container: " + satellitePreview,
249+
satellitePreview.stream().anyMatch(summary -> summary.startsWith("1 records from misc charges")));
250+
251+
manager.deleteBillingRuns(_user, _containerA, List.of(runIdWithSatelliteMiscCharge), false);
252+
assertEquals("miscCharges row in the satellite source container should be detached from the deleted invoice", 0, miscChargesWithInvoiceCount(_containerSatellite));
253+
assertEquals("miscCharges row in the satellite source container should not be deleted", 1, containerRowCount(getTable(schema, ONPRC_BillingSchema.TABLE_MISC_CHARGES), _containerSatellite));
254+
255+
// Positive control: deleting a run from its own container removes its rows.
256+
manager.deleteBillingRuns(_user, _containerA, List.of(_runIdA), false);
257+
assertEquals("invoiceRuns row in container A should be deleted", 0, containerRowCount(getTable(schema, ONPRC_BillingSchema.TABLE_INVOICE_RUNS), _containerA));
258+
assertEquals("invoicedItems row in container A should be deleted", 0, containerRowCount(getTable(schema, ONPRC_BillingSchema.TABLE_INVOICED_ITEMS), _containerA));
259+
assertEquals("miscCharges row in container A should be detached from the deleted invoice", 0, miscChargesWithInvoiceCount(_containerA));
260+
assertEquals("miscCharges row in container A should not be deleted", 1, containerRowCount(getTable(schema, ONPRC_BillingSchema.TABLE_MISC_CHARGES), _containerA));
261+
}
262+
263+
private Container createBillingFolder(Container parent, String name)
264+
{
265+
Container c = ContainerManager.createContainer(parent, name, _user);
266+
Set<Module> active = new HashSet<>(c.getActiveModules());
267+
active.add(ModuleLoader.getInstance().getModule(ONPRC_BillingModule.NAME));
268+
c.setActiveModules(active, _user);
269+
return c;
270+
}
271+
272+
private String insertBillingRun(Container c)
273+
{
274+
return insertBillingRun(c, c);
275+
}
276+
277+
private String insertBillingRun(Container billingContainer, Container miscChargesContainer)
278+
{
279+
ONPRC_BillingSchema schema = ONPRC_BillingSchema.getInstance();
280+
String runId = GUID.makeGUID();
281+
282+
Map<String, Object> run = new CaseInsensitiveHashMap<>();
283+
run.put("objectid", runId);
284+
run.put("runDate", new Date());
285+
run.put("billingPeriodStart", new Date());
286+
run.put("billingPeriodEnd", new Date());
287+
run.put("container", billingContainer.getId());
288+
Table.insert(_user, getTable(schema, ONPRC_BillingSchema.TABLE_INVOICE_RUNS), run);
289+
290+
Map<String, Object> invoicedItem = new CaseInsensitiveHashMap<>();
291+
invoicedItem.put("objectid", GUID.makeGUID());
292+
invoicedItem.put("invoiceId", runId);
293+
invoicedItem.put("container", billingContainer.getId());
294+
Table.insert(_user, getTable(schema, ONPRC_BillingSchema.TABLE_INVOICED_ITEMS), invoicedItem);
295+
296+
Map<String, Object> miscCharge = new CaseInsensitiveHashMap<>();
297+
miscCharge.put("objectid", GUID.makeGUID());
298+
miscCharge.put("invoiceId", runId);
299+
miscCharge.put("container", miscChargesContainer.getId());
300+
Table.insert(_user, getTable(schema, ONPRC_BillingSchema.TABLE_MISC_CHARGES), miscCharge);
301+
302+
return runId;
303+
}
304+
305+
private TableInfo getTable(ONPRC_BillingSchema schema, String tableName)
306+
{
307+
return schema.getSchema().getTable(tableName);
308+
}
309+
310+
private long containerRowCount(TableInfo table, Container c)
311+
{
312+
return new TableSelector(table, SimpleFilter.createContainerFilter(c), null).getRowCount();
313+
}
314+
315+
private long miscChargesWithInvoiceCount(Container c)
316+
{
317+
SimpleFilter filter = SimpleFilter.createContainerFilter(c);
318+
filter.addCondition(FieldKey.fromParts("invoiceId"), null, CompareType.NONBLANK);
319+
return new TableSelector(getTable(ONPRC_BillingSchema.getInstance(), ONPRC_BillingSchema.TABLE_MISC_CHARGES), filter, null).getRowCount();
320+
}
321+
322+
private void deleteTestFolders()
323+
{
324+
Container junit = JunitUtil.getTestContainer();
325+
for (String name : List.of(FOLDER_A, FOLDER_B, FOLDER_SATELLITE))
326+
{
327+
Container c = junit.getChild(name);
328+
if (c != null)
329+
ContainerManager.delete(c, _user);
330+
}
331+
}
332+
}
142333
}

onprc_billing/src/org/labkey/onprc_billing/ONPRC_BillingModule.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,13 @@ public Set<String> getSchemaNames()
166166
return Collections.singleton(ONPRC_BillingSchema.NAME);
167167
}
168168

169+
@Override
170+
@NotNull
171+
public Set<Class<?>> getIntegrationTests()
172+
{
173+
return Collections.singleton(ONPRC_BillingManager.TestCase.class);
174+
}
175+
169176
@Override
170177
protected void registerSchemas()
171178
{

onprc_ehr/resources/queries/study/ParentageDamMismatch.sql

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,49 +18,64 @@
1818
* 6. Excludes old parentage entries, enddate IS BLANK
1919
*/
2020

21+
/*
22+
Modifications by Kolli, June 2026
23+
Refer tkt# 14891 for details
24+
25+
* 1. Remove cases in which the ID has a surrogate dam entered in prime. (There are several but 41043 is one example)
26+
* 2. Remove cases in which the observed dam is "unknown"
27+
* 3. Remove cases in which the observed dam ID is not an ONPRC. E.g. there are several NEPRC IDs of the format ###-####.
28+
*/
29+
2130
SELECT
2231
d.Id,
2332
d.Id.curLocation.area AS Area,
24-
coalesce(p2.parent, '') as geneticDam,
25-
coalesce(b.dam, '') as observedDam
33+
gd.geneticDam,
34+
b.dam AS observedDam
35+
2636
FROM study.demographics d
2737

28-
LEFT JOIN (
38+
LEFT JOIN (
2939
SELECT
30-
p2.Id,
31-
MAX(p2.parent) AS parent
32-
FROM study.parentage p2
33-
WHERE (p2.method = 'Genetic' OR p2.method = 'Provisional Genetic')
34-
AND p2.relationship = 'Dam'
35-
AND p2.enddate IS NULL
36-
GROUP BY p2.Id
37-
) p2 ON d.Id = p2.Id
40+
p.Id,
41+
MAX(p.parent) AS geneticDam
42+
FROM study.parentage p
43+
WHERE p.relationship = 'Dam'
44+
AND p.method IN ('Genetic', 'Provisional Genetic')
45+
AND p.enddate IS NULL
46+
GROUP BY p.Id
47+
) gd
48+
ON gd.Id = d.Id
3849

39-
LEFT JOIN (
40-
SELECT
41-
p3.Id,
42-
MAX(p3.parent) AS parent
43-
FROM study.parentage p3
44-
WHERE p3.relationship = 'Foster Dam'
45-
AND p3.enddate IS NULL
46-
GROUP BY p3.Id
47-
) p3 ON d.Id = p3.Id
50+
LEFT JOIN study.birth b
51+
ON b.Id = d.Id
4852

49-
LEFT JOIN study.birth b
50-
ON b.Id = d.Id
53+
WHERE d.calculated_status.code IN ('Alive', 'Dead')
54+
AND d.qcstate = 18
5155

52-
WHERE d.calculated_status.code IN ('Alive', 'Dead') AND d.qcstate = 18
53-
/* exclude foster-dam cases (NULL or blank only) */
54-
AND COALESCE(RTRIM(LTRIM(CAST(p3.parent AS VARCHAR(50)))), '') = ''
56+
/* exclude animals with active Foster Dam */
57+
/* And, exclude animals with active Surrogate Dam */
58+
AND NOT EXISTS (
59+
SELECT 1
60+
FROM study.parentage fd
61+
WHERE fd.Id = d.Id
62+
AND fd.relationship IN ('Foster Dam', 'Surrogate Dam')
63+
AND fd.enddate IS NULL
64+
AND COALESCE(RTRIM(LTRIM(CAST(fd.parent AS VARCHAR(50)))), '') <> ''
65+
)
5566

56-
/* exclude blank observed dam */
67+
/* observed dam is not blank */
5768
AND COALESCE(RTRIM(LTRIM(CAST(b.dam AS VARCHAR(50)))), '') <> ''
5869

59-
/* exclude blank genetic dam */
60-
AND COALESCE(RTRIM(LTRIM(CAST(p2.parent AS VARCHAR(50)))), '') <> ''
70+
/* observed dam is not unknown */
71+
AND LOWER(COALESCE(RTRIM(LTRIM(CAST(b.dam AS VARCHAR(50)))), '')) <> 'unknown'
6172

62-
/* mismatch observed vs genetic */
63-
AND COALESCE(RTRIM(LTRIM(CAST(b.dam AS VARCHAR(50)))), '') <>
64-
COALESCE(RTRIM(LTRIM(CAST(p2.parent AS VARCHAR(50)))), '')
73+
/* observed dam is an ONPRC ID, not external format like ###-#### */
74+
AND COALESCE(RTRIM(LTRIM(CAST(b.dam AS VARCHAR(50)))), '') NOT LIKE '%-%'
6575

76+
/* genetic dam is not blank */
77+
AND COALESCE(RTRIM(LTRIM(CAST(gd.geneticDam AS VARCHAR(50)))), '') <> ''
6678

79+
/* observed dam does not match genetic dam */
80+
AND COALESCE(RTRIM(LTRIM(CAST(b.dam AS VARCHAR(50)))), '') <>
81+
COALESCE(RTRIM(LTRIM(CAST(gd.geneticDam AS VARCHAR(50)))), '');

0 commit comments

Comments
 (0)