]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/sql/Pg/100.circ_matrix.sql
LP#1842940: Improve Alert
[Evergreen.git] / Open-ILS / src / sql / Pg / 100.circ_matrix.sql
1
2 BEGIN;
3 -- NOTE: current config.item_type should get sip2_media_type and magnetic_media columns
4
5 -- New table needed to handle circ modifiers inside the DB.  Will still require
6 -- central admin.  The circ_modifier column on asset.copy will become an fkey to this table.
7 CREATE TABLE config.circ_modifier (
8     code            TEXT    PRIMARY KEY,
9     name            TEXT    UNIQUE NOT NULL,
10     description        TEXT    NOT NULL,
11     sip2_media_type    TEXT    NOT NULL,
12     magnetic_media     BOOL    NOT NULL DEFAULT TRUE,
13     avg_wait_time      INTERVAL
14 );
15
16 /*
17 -- for instance ...
18 INSERT INTO config.circ_modifier VALUES ( 'DVD', 'DVD', 'um ... DVDs', '001', FALSE );
19 INSERT INTO config.circ_modifier VALUES ( 'VIDEO', 'VIDEO', 'Tapes', '001', TRUE );
20 INSERT INTO config.circ_modifier VALUES ( 'BOOK', 'BOOK', 'Dead tree', '001', FALSE );
21 INSERT INTO config.circ_modifier VALUES ( 'CRAZY_ARL-ATH_SETTING', 'R2R_TAPE', 'reel2reel tape', '007', TRUE );
22 */
23
24 -- But, just to get us started, use this
25 /*
26
27 UPDATE asset.copy SET circ_modifier = UPPER(circ_modifier) WHERE circ_modifier IS NOT NULL AND circ_modifier <> '';
28 UPDATE asset.copy SET circ_modifier = NULL WHERE circ_modifier = '';
29
30 INSERT INTO config.circ_modifier (code, name, description, sip2_media_type )
31     SELECT DISTINCT
32             UPPER(circ_modifier),
33             UPPER(circ_modifier),
34             LOWER(circ_modifier),
35             '001'
36       FROM  asset.copy
37       WHERE circ_modifier IS NOT NULL;
38
39 */
40
41 -- add an fkey pointing to the new circ mod table
42 ALTER TABLE asset.copy ADD CONSTRAINT circ_mod_fkey FOREIGN KEY (circ_modifier) REFERENCES config.circ_modifier (code) ON UPDATE CASCADE ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;
43
44
45 /**
46  **  Here we define the tables that make up the circ matrix.  Conceptually, this implements
47  **  the "sparse matrix" that everyone talks about, instead of using traditional rules logic.
48  **  Physically, we cut the matrix up into separate tables (almost 3rd normal form!) that handle
49  **  different portions of the matrix.  This wil simplify creation of the UI (I hope), and help the
50  **  developers focus on specific parts of the matrix.
51  **/
52
53 CREATE TABLE config.circ_matrix_matchpoint (
54     id                   SERIAL    PRIMARY KEY,
55     active               BOOL    NOT NULL DEFAULT TRUE,
56     -- Match Fields
57     org_unit             INT        NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,    -- Set to the top OU for the matchpoint applicability range; we can use org_unit_prox to choose the "best"
58     grp                  INT     NOT NULL REFERENCES permission.grp_tree (id) DEFERRABLE INITIALLY DEFERRED,    -- Set to the top applicable group from the group tree; will need descendents and prox functions for filtering
59     circ_modifier        TEXT    REFERENCES config.circ_modifier (code) DEFERRABLE INITIALLY DEFERRED,
60     copy_location        INT     REFERENCES asset.copy_location (id) DEFERRABLE INITIALLY DEFERRED,
61     marc_type            TEXT,
62     marc_form            TEXT,
63     marc_bib_level       TEXT,
64     marc_vr_format       TEXT,
65     copy_circ_lib        INT     REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
66     copy_owning_lib      INT     REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
67     user_home_ou         INT     REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
68     ref_flag             BOOL,
69     juvenile_flag        BOOL,
70     is_renewal           BOOL,
71     usr_age_lower_bound  INTERVAL,
72     usr_age_upper_bound  INTERVAL,
73     item_age             INTERVAL,
74     -- "Result" Fields
75     circulate            BOOL,   -- Hard "can't circ" flag requiring an override
76     duration_rule        INT     REFERENCES config.rule_circ_duration (id) DEFERRABLE INITIALLY DEFERRED,
77     recurring_fine_rule  INT     REFERENCES config.rule_recurring_fine (id) DEFERRABLE INITIALLY DEFERRED,
78     max_fine_rule        INT     REFERENCES config.rule_max_fine (id) DEFERRABLE INITIALLY DEFERRED,
79     hard_due_date        INT     REFERENCES config.hard_due_date (id) DEFERRABLE INITIALLY DEFERRED,
80     renewals             INT,    -- Renewal count override
81     grace_period         INTERVAL,    -- Grace period override
82     script_test          TEXT,                           -- javascript source 
83     total_copy_hold_ratio     FLOAT,
84     available_copy_hold_ratio FLOAT,
85     description          TEXT
86 );
87
88 -- Nulls don't count for a constraint match, so we have to coalesce them into something that does.
89 CREATE UNIQUE INDEX ccmm_once_per_paramset ON config.circ_matrix_matchpoint (org_unit, grp, COALESCE(circ_modifier, ''), COALESCE(copy_location::TEXT, ''), COALESCE(marc_type, ''), COALESCE(marc_form, ''), COALESCE(marc_bib_level,''), COALESCE(marc_vr_format, ''), COALESCE(copy_circ_lib::TEXT, ''), COALESCE(copy_owning_lib::TEXT, ''), COALESCE(user_home_ou::TEXT, ''), COALESCE(ref_flag::TEXT, ''), COALESCE(juvenile_flag::TEXT, ''), COALESCE(is_renewal::TEXT, ''), COALESCE(usr_age_lower_bound::TEXT, ''), COALESCE(usr_age_upper_bound::TEXT, ''), COALESCE(item_age::TEXT, '')) WHERE active;
90
91 -- Limit groups for circ counting
92 CREATE TABLE config.circ_limit_group (
93     id          SERIAL  PRIMARY KEY,
94     name        TEXT    UNIQUE NOT NULL,
95     description TEXT
96 );
97
98 -- Limit sets
99 CREATE TABLE config.circ_limit_set (
100     id          SERIAL  PRIMARY KEY,
101     name        TEXT    UNIQUE NOT NULL,
102     owning_lib  INT     NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
103     items_out   INT     NOT NULL, -- Total current active circulations must be less than this. 0 means skip counting (always pass)
104     depth       INT     NOT NULL DEFAULT 0, -- Depth count starts at
105     global      BOOL    NOT NULL DEFAULT FALSE, -- If enabled, include everything below depth, otherwise ancestors/descendants only
106     description TEXT
107 );
108
109 -- Linkage between matchpoints and limit sets
110 CREATE TABLE config.circ_matrix_limit_set_map (
111     id          SERIAL  PRIMARY KEY,
112     matchpoint  INT     NOT NULL REFERENCES config.circ_matrix_matchpoint (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
113     limit_set   INT     NOT NULL REFERENCES config.circ_limit_set (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
114     fallthrough BOOL    NOT NULL DEFAULT FALSE, -- If true fallthrough will grab this rule as it goes along
115     active      BOOL    NOT NULL DEFAULT TRUE,
116     CONSTRAINT circ_limit_set_once_per_matchpoint UNIQUE (matchpoint, limit_set)
117 );
118
119 -- Linkage between limit sets and circ mods
120 CREATE TABLE config.circ_limit_set_circ_mod_map (
121     id          SERIAL  PRIMARY KEY,
122     limit_set   INT     NOT NULL REFERENCES config.circ_limit_set (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
123     circ_mod    TEXT    NOT NULL REFERENCES config.circ_modifier (code) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
124     CONSTRAINT cm_once_per_set UNIQUE (limit_set, circ_mod)
125 );
126
127 -- Linkage between limit sets and copy locations
128 CREATE TABLE config.circ_limit_set_copy_loc_map (
129     id          SERIAL  PRIMARY KEY,
130     limit_set   INT     NOT NULL REFERENCES config.circ_limit_set (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
131     copy_loc    INT     NOT NULL REFERENCES asset.copy_location (id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
132     CONSTRAINT cl_once_per_set UNIQUE (limit_set, copy_loc)
133 );
134
135 -- Linkage between limit sets and limit groups
136 CREATE TABLE config.circ_limit_set_group_map (
137     id          SERIAL  PRIMARY KEY,
138     limit_set    INT     NOT NULL REFERENCES config.circ_limit_set (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
139     limit_group INT     NOT NULL REFERENCES config.circ_limit_group (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
140     check_only  BOOL    NOT NULL DEFAULT FALSE, -- If true, don't accumulate this limit_group for storing with the circulation
141     CONSTRAINT clg_once_per_set UNIQUE (limit_set, limit_group)
142 );
143
144 -- Linkage between limit groups and circulations
145 CREATE TABLE action.circulation_limit_group_map (
146     circ        BIGINT      NOT NULL REFERENCES action.circulation (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
147     limit_group INT         NOT NULL REFERENCES config.circ_limit_group (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
148     PRIMARY KEY (circ, limit_group)
149 );
150
151 -- Function for populating the circ/limit group mappings
152 CREATE OR REPLACE FUNCTION action.link_circ_limit_groups ( BIGINT, INT[] ) RETURNS VOID AS $func$
153     INSERT INTO action.circulation_limit_group_map(circ, limit_group) SELECT $1, id FROM config.circ_limit_group WHERE id IN (SELECT * FROM UNNEST($2));
154 $func$ LANGUAGE SQL;
155
156 CREATE TYPE action.found_circ_matrix_matchpoint AS ( success BOOL, matchpoint config.circ_matrix_matchpoint, buildrows INT[] );
157
158 CREATE OR REPLACE FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, item_object asset.copy, user_object actor.usr, renewal BOOL ) RETURNS action.found_circ_matrix_matchpoint AS $func$
159 DECLARE
160     cn_object       asset.call_number%ROWTYPE;
161     rec_descriptor  metabib.rec_descriptor%ROWTYPE;
162     cur_matchpoint  config.circ_matrix_matchpoint%ROWTYPE;
163     matchpoint      config.circ_matrix_matchpoint%ROWTYPE;
164     weights         config.circ_matrix_weights%ROWTYPE;
165     user_age        INTERVAL;
166     my_item_age     INTERVAL;
167     denominator     NUMERIC(6,2);
168     row_list        INT[];
169     result          action.found_circ_matrix_matchpoint;
170 BEGIN
171     -- Assume failure
172     result.success = false;
173
174     -- Fetch useful data
175     SELECT INTO cn_object       * FROM asset.call_number        WHERE id = item_object.call_number;
176     SELECT INTO rec_descriptor  * FROM metabib.rec_descriptor   WHERE record = cn_object.record;
177
178     -- Pre-generate this so we only calc it once
179     IF user_object.dob IS NOT NULL THEN
180         SELECT INTO user_age age(user_object.dob);
181     END IF;
182
183     -- Ditto
184     SELECT INTO my_item_age age(coalesce(item_object.active_date, now()));
185
186     -- Grab the closest set circ weight setting.
187     SELECT INTO weights cw.*
188       FROM config.weight_assoc wa
189            JOIN config.circ_matrix_weights cw ON (cw.id = wa.circ_weights)
190            JOIN actor.org_unit_ancestors_distance( context_ou ) d ON (wa.org_unit = d.id)
191       WHERE active
192       ORDER BY d.distance
193       LIMIT 1;
194
195     -- No weights? Bad admin! Defaults to handle that anyway.
196     IF weights.id IS NULL THEN
197         weights.grp                 := 11.0;
198         weights.org_unit            := 10.0;
199         weights.circ_modifier       := 5.0;
200         weights.copy_location       := 5.0;
201         weights.marc_type           := 4.0;
202         weights.marc_form           := 3.0;
203         weights.marc_bib_level      := 2.0;
204         weights.marc_vr_format      := 2.0;
205         weights.copy_circ_lib       := 8.0;
206         weights.copy_owning_lib     := 8.0;
207         weights.user_home_ou        := 8.0;
208         weights.ref_flag            := 1.0;
209         weights.juvenile_flag       := 6.0;
210         weights.is_renewal          := 7.0;
211         weights.usr_age_lower_bound := 0.0;
212         weights.usr_age_upper_bound := 0.0;
213         weights.item_age            := 0.0;
214     END IF;
215
216     -- Determine the max (expected) depth (+1) of the org tree and max depth of the permisson tree
217     -- If you break your org tree with funky parenting this may be wrong
218     -- Note: This CTE is duplicated in the find_hold_matrix_matchpoint function, and it may be a good idea to split it off to a function
219     -- We use one denominator for all tree-based checks for when permission groups and org units have the same weighting
220     WITH all_distance(distance) AS (
221             SELECT depth AS distance FROM actor.org_unit_type
222         UNION
223             SELECT distance AS distance FROM permission.grp_ancestors_distance((SELECT id FROM permission.grp_tree WHERE parent IS NULL))
224         )
225     SELECT INTO denominator MAX(distance) + 1 FROM all_distance;
226
227     -- Loop over all the potential matchpoints
228     FOR cur_matchpoint IN
229         SELECT m.*
230           FROM  config.circ_matrix_matchpoint m
231                 /*LEFT*/ JOIN permission.grp_ancestors_distance( user_object.profile ) upgad ON m.grp = upgad.id
232                 /*LEFT*/ JOIN actor.org_unit_ancestors_distance( context_ou ) ctoua ON m.org_unit = ctoua.id
233                 LEFT JOIN actor.org_unit_ancestors_distance( cn_object.owning_lib ) cnoua ON m.copy_owning_lib = cnoua.id
234                 LEFT JOIN actor.org_unit_ancestors_distance( item_object.circ_lib ) iooua ON m.copy_circ_lib = iooua.id
235                 LEFT JOIN actor.org_unit_ancestors_distance( user_object.home_ou  ) uhoua ON m.user_home_ou = uhoua.id
236           WHERE m.active
237                 -- Permission Groups
238              -- AND (m.grp                      IS NULL OR upgad.id IS NOT NULL) -- Optional Permission Group?
239                 -- Org Units
240              -- AND (m.org_unit                 IS NULL OR ctoua.id IS NOT NULL) -- Optional Org Unit?
241                 AND (m.copy_owning_lib          IS NULL OR cnoua.id IS NOT NULL)
242                 AND (m.copy_circ_lib            IS NULL OR iooua.id IS NOT NULL)
243                 AND (m.user_home_ou             IS NULL OR uhoua.id IS NOT NULL)
244                 -- Circ Type
245                 AND (m.is_renewal               IS NULL OR m.is_renewal = renewal)
246                 -- Static User Checks
247                 AND (m.juvenile_flag            IS NULL OR m.juvenile_flag = user_object.juvenile)
248                 AND (m.usr_age_lower_bound      IS NULL OR (user_age IS NOT NULL AND m.usr_age_lower_bound < user_age))
249                 AND (m.usr_age_upper_bound      IS NULL OR (user_age IS NOT NULL AND m.usr_age_upper_bound > user_age))
250                 -- Static Item Checks
251                 AND (m.circ_modifier            IS NULL OR m.circ_modifier = item_object.circ_modifier)
252                 AND (m.copy_location            IS NULL OR m.copy_location = item_object.location)
253                 AND (m.marc_type                IS NULL OR m.marc_type = COALESCE(item_object.circ_as_type, rec_descriptor.item_type))
254                 AND (m.marc_form                IS NULL OR m.marc_form = rec_descriptor.item_form)
255                 AND (m.marc_bib_level           IS NULL OR m.marc_bib_level = rec_descriptor.bib_level)
256                 AND (m.marc_vr_format           IS NULL OR m.marc_vr_format = rec_descriptor.vr_format)
257                 AND (m.ref_flag                 IS NULL OR m.ref_flag = item_object.ref)
258                 AND (m.item_age                 IS NULL OR (my_item_age IS NOT NULL AND m.item_age > my_item_age))
259           ORDER BY
260                 -- Permission Groups
261                 CASE WHEN upgad.distance        IS NOT NULL THEN 2^(2*weights.grp - (upgad.distance/denominator)) ELSE 0.0 END +
262                 -- Org Units
263                 CASE WHEN ctoua.distance        IS NOT NULL THEN 2^(2*weights.org_unit - (ctoua.distance/denominator)) ELSE 0.0 END +
264                 CASE WHEN cnoua.distance        IS NOT NULL THEN 2^(2*weights.copy_owning_lib - (cnoua.distance/denominator)) ELSE 0.0 END +
265                 CASE WHEN iooua.distance        IS NOT NULL THEN 2^(2*weights.copy_circ_lib - (iooua.distance/denominator)) ELSE 0.0 END +
266                 CASE WHEN uhoua.distance        IS NOT NULL THEN 2^(2*weights.user_home_ou - (uhoua.distance/denominator)) ELSE 0.0 END +
267                 -- Circ Type                    -- Note: 4^x is equiv to 2^(2*x)
268                 CASE WHEN m.is_renewal          IS NOT NULL THEN 4^weights.is_renewal ELSE 0.0 END +
269                 -- Static User Checks
270                 CASE WHEN m.juvenile_flag       IS NOT NULL THEN 4^weights.juvenile_flag ELSE 0.0 END +
271                 CASE WHEN m.usr_age_lower_bound IS NOT NULL THEN 4^weights.usr_age_lower_bound ELSE 0.0 END +
272                 CASE WHEN m.usr_age_upper_bound IS NOT NULL THEN 4^weights.usr_age_upper_bound ELSE 0.0 END +
273                 -- Static Item Checks
274                 CASE WHEN m.circ_modifier       IS NOT NULL THEN 4^weights.circ_modifier ELSE 0.0 END +
275                 CASE WHEN m.copy_location       IS NOT NULL THEN 4^weights.copy_location ELSE 0.0 END +
276                 CASE WHEN m.marc_type           IS NOT NULL THEN 4^weights.marc_type ELSE 0.0 END +
277                 CASE WHEN m.marc_form           IS NOT NULL THEN 4^weights.marc_form ELSE 0.0 END +
278                 CASE WHEN m.marc_vr_format      IS NOT NULL THEN 4^weights.marc_vr_format ELSE 0.0 END +
279                 CASE WHEN m.ref_flag            IS NOT NULL THEN 4^weights.ref_flag ELSE 0.0 END +
280                 -- Item age has a slight adjustment to weight based on value.
281                 -- This should ensure that a shorter age limit comes first when all else is equal.
282                 -- NOTE: This assumes that intervals will normally be in days.
283                 CASE WHEN m.item_age            IS NOT NULL THEN 4^weights.item_age - 1 + 86400/EXTRACT(EPOCH FROM m.item_age) ELSE 0.0 END DESC,
284                 -- Final sort on id, so that if two rules have the same sorting in the previous sort they have a defined order
285                 -- This prevents "we changed the table order by updating a rule, and we started getting different results"
286                 m.id LOOP
287
288         -- Record the full matching row list
289         row_list := row_list || cur_matchpoint.id;
290
291         -- No matchpoint yet?
292         IF matchpoint.id IS NULL THEN
293             -- Take the entire matchpoint as a starting point
294             matchpoint := cur_matchpoint;
295             CONTINUE; -- No need to look at this row any more.
296         END IF;
297
298         -- Incomplete matchpoint?
299         IF matchpoint.circulate IS NULL THEN
300             matchpoint.circulate := cur_matchpoint.circulate;
301         END IF;
302         IF matchpoint.duration_rule IS NULL THEN
303             matchpoint.duration_rule := cur_matchpoint.duration_rule;
304         END IF;
305         IF matchpoint.recurring_fine_rule IS NULL THEN
306             matchpoint.recurring_fine_rule := cur_matchpoint.recurring_fine_rule;
307         END IF;
308         IF matchpoint.max_fine_rule IS NULL THEN
309             matchpoint.max_fine_rule := cur_matchpoint.max_fine_rule;
310         END IF;
311         IF matchpoint.hard_due_date IS NULL THEN
312             matchpoint.hard_due_date := cur_matchpoint.hard_due_date;
313         END IF;
314         IF matchpoint.total_copy_hold_ratio IS NULL THEN
315             matchpoint.total_copy_hold_ratio := cur_matchpoint.total_copy_hold_ratio;
316         END IF;
317         IF matchpoint.available_copy_hold_ratio IS NULL THEN
318             matchpoint.available_copy_hold_ratio := cur_matchpoint.available_copy_hold_ratio;
319         END IF;
320         IF matchpoint.renewals IS NULL THEN
321             matchpoint.renewals := cur_matchpoint.renewals;
322         END IF;
323         IF matchpoint.grace_period IS NULL THEN
324             matchpoint.grace_period := cur_matchpoint.grace_period;
325         END IF;
326     END LOOP;
327
328     -- Check required fields
329     IF matchpoint.circulate             IS NOT NULL AND
330        matchpoint.duration_rule         IS NOT NULL AND
331        matchpoint.recurring_fine_rule   IS NOT NULL AND
332        matchpoint.max_fine_rule         IS NOT NULL THEN
333         -- All there? We have a completed match.
334         result.success := true;
335     END IF;
336
337     -- Include the assembled matchpoint, even if it isn't complete
338     result.matchpoint := matchpoint;
339
340     -- Include (for debugging) the full list of matching rows
341     result.buildrows := row_list;
342
343     -- Hand the result back to caller
344     RETURN result;
345 END;
346 $func$ LANGUAGE plpgsql;
347
348 -- Helper function - For manual calling, it can be easier to pass in IDs instead of objects
349 CREATE OR REPLACE FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.found_circ_matrix_matchpoint AS $func$
350 DECLARE
351     item_object asset.copy%ROWTYPE;
352     user_object actor.usr%ROWTYPE;
353 BEGIN
354     SELECT INTO item_object * FROM asset.copy   WHERE id = match_item;
355     SELECT INTO user_object * FROM actor.usr    WHERE id = match_user;
356
357     RETURN QUERY SELECT * FROM action.find_circ_matrix_matchpoint( context_ou, item_object, user_object, renewal );
358 END;
359 $func$ LANGUAGE plpgsql;
360
361 CREATE TYPE action.hold_stats AS (
362     hold_count              INT,
363     copy_count              INT,
364     available_count         INT,
365     total_copy_ratio        FLOAT,
366     available_copy_ratio    FLOAT
367 );
368
369 CREATE OR REPLACE FUNCTION action.copy_related_hold_stats (copy_id BIGINT) RETURNS action.hold_stats AS $func$
370 DECLARE
371     output          action.hold_stats%ROWTYPE;
372     hold_count      INT := 0;
373     copy_count      INT := 0;
374     available_count INT := 0;
375     hold_map_data   RECORD;
376 BEGIN
377
378     output.hold_count := 0;
379     output.copy_count := 0;
380     output.available_count := 0;
381
382     SELECT  COUNT( DISTINCT m.hold ) INTO hold_count
383       FROM  action.hold_copy_map m
384             JOIN action.hold_request h ON (m.hold = h.id)
385       WHERE m.target_copy = copy_id
386             AND NOT h.frozen;
387
388     output.hold_count := hold_count;
389
390     IF output.hold_count > 0 THEN
391         FOR hold_map_data IN
392             SELECT  DISTINCT m.target_copy,
393                     acp.status
394               FROM  action.hold_copy_map m
395                     JOIN asset.copy acp ON (m.target_copy = acp.id)
396                     JOIN action.hold_request h ON (m.hold = h.id)
397               WHERE m.hold IN ( SELECT DISTINCT hold FROM action.hold_copy_map WHERE target_copy = copy_id ) AND NOT h.frozen
398         LOOP
399             output.copy_count := output.copy_count + 1;
400             IF hold_map_data.status IN (0,7,12) THEN
401                 output.available_count := output.available_count + 1;
402             END IF;
403         END LOOP;
404         output.total_copy_ratio = output.copy_count::FLOAT / output.hold_count::FLOAT;
405         output.available_copy_ratio = output.available_count::FLOAT / output.hold_count::FLOAT;
406
407     END IF;
408
409     RETURN output;
410
411 END;
412 $func$ LANGUAGE PLPGSQL;
413
414 CREATE TYPE action.circ_matrix_test_result AS ( success BOOL, fail_part TEXT, buildrows INT[], matchpoint INT, circulate BOOL, duration_rule INT, recurring_fine_rule INT, max_fine_rule INT, hard_due_date INT, renewals INT, grace_period INTERVAL, limit_groups INT[] );
415 CREATE OR REPLACE FUNCTION action.item_user_circ_test( circ_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.circ_matrix_test_result AS $func$
416 DECLARE
417     user_object             actor.usr%ROWTYPE;
418     standing_penalty        config.standing_penalty%ROWTYPE;
419     item_object             asset.copy%ROWTYPE;
420     item_status_object      config.copy_status%ROWTYPE;
421     item_location_object    asset.copy_location%ROWTYPE;
422     result                  action.circ_matrix_test_result;
423     circ_test               action.found_circ_matrix_matchpoint;
424     circ_matchpoint         config.circ_matrix_matchpoint%ROWTYPE;
425     circ_limit_set          config.circ_limit_set%ROWTYPE;
426     hold_ratio              action.hold_stats%ROWTYPE;
427     penalty_type            TEXT;
428     items_out               INT;
429     context_org_list        INT[];
430     done                    BOOL := FALSE;
431     item_prox               INT;
432     home_prox               INT;
433 BEGIN
434     -- Assume success unless we hit a failure condition
435     result.success := TRUE;
436
437     -- Need user info to look up matchpoints
438     SELECT INTO user_object * FROM actor.usr WHERE id = match_user AND NOT deleted;
439
440     -- (Insta)Fail if we couldn't find the user
441     IF user_object.id IS NULL THEN
442         result.fail_part := 'no_user';
443         result.success := FALSE;
444         done := TRUE;
445         RETURN NEXT result;
446         RETURN;
447     END IF;
448
449     -- Need item info to look up matchpoints
450     SELECT INTO item_object * FROM asset.copy WHERE id = match_item AND NOT deleted;
451
452     -- (Insta)Fail if we couldn't find the item 
453     IF item_object.id IS NULL THEN
454         result.fail_part := 'no_item';
455         result.success := FALSE;
456         done := TRUE;
457         RETURN NEXT result;
458         RETURN;
459     END IF;
460
461     SELECT INTO circ_test * FROM action.find_circ_matrix_matchpoint(circ_ou, item_object, user_object, renewal);
462
463     circ_matchpoint             := circ_test.matchpoint;
464     result.matchpoint           := circ_matchpoint.id;
465     result.circulate            := circ_matchpoint.circulate;
466     result.duration_rule        := circ_matchpoint.duration_rule;
467     result.recurring_fine_rule  := circ_matchpoint.recurring_fine_rule;
468     result.max_fine_rule        := circ_matchpoint.max_fine_rule;
469     result.hard_due_date        := circ_matchpoint.hard_due_date;
470     result.renewals             := circ_matchpoint.renewals;
471     result.grace_period         := circ_matchpoint.grace_period;
472     result.buildrows            := circ_test.buildrows;
473
474     -- (Insta)Fail if we couldn't find a matchpoint
475     IF circ_test.success = false THEN
476         result.fail_part := 'no_matchpoint';
477         result.success := FALSE;
478         done := TRUE;
479         RETURN NEXT result;
480         RETURN;
481     END IF;
482
483     -- All failures before this point are non-recoverable
484     -- Below this point are possibly overridable failures
485
486     -- Fail if the user is barred
487     IF user_object.barred IS TRUE THEN
488         result.fail_part := 'actor.usr.barred';
489         result.success := FALSE;
490         done := TRUE;
491         RETURN NEXT result;
492     END IF;
493
494     -- Fail if the item can't circulate
495     IF item_object.circulate IS FALSE THEN
496         result.fail_part := 'asset.copy.circulate';
497         result.success := FALSE;
498         done := TRUE;
499         RETURN NEXT result;
500     END IF;
501
502     -- Fail if the item isn't in a circulateable status on a non-renewal
503     IF NOT renewal AND item_object.status <> 8 AND item_object.status NOT IN (
504         (SELECT id FROM config.copy_status WHERE is_available) ) THEN 
505         result.fail_part := 'asset.copy.status';
506         result.success := FALSE;
507         done := TRUE;
508         RETURN NEXT result;
509     -- Alternately, fail if the item isn't checked out on a renewal
510     ELSIF renewal AND item_object.status <> 1 THEN
511         result.fail_part := 'asset.copy.status';
512         result.success := FALSE;
513         done := TRUE;
514         RETURN NEXT result;
515     END IF;
516
517     -- Fail if the item can't circulate because of the shelving location
518     SELECT INTO item_location_object * FROM asset.copy_location WHERE id = item_object.location;
519     IF item_location_object.circulate IS FALSE THEN
520         result.fail_part := 'asset.copy_location.circulate';
521         result.success := FALSE;
522         done := TRUE;
523         RETURN NEXT result;
524     END IF;
525
526     -- Use Circ OU for penalties and such
527     SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( circ_ou );
528
529     -- Proximity of user's home_ou to circ_ou to see if penalties should be ignored.
530     SELECT INTO home_prox prox FROM actor.org_unit_proximity WHERE from_org = user_object.home_ou AND to_org = circ_ou;
531
532     -- Proximity of user's home_ou to item circ_lib to see if penalties should be ignored.
533     SELECT INTO item_prox prox FROM actor.org_unit_proximity WHERE from_org = user_object.home_ou AND to_org = item_object.circ_lib;
534
535     IF renewal THEN
536         penalty_type = '%RENEW%';
537     ELSE
538         penalty_type = '%CIRC%';
539     END IF;
540
541     FOR standing_penalty IN
542         SELECT  DISTINCT csp.*
543           FROM  actor.usr_standing_penalty usp
544                 JOIN config.standing_penalty csp ON (csp.id = usp.standing_penalty)
545           WHERE usr = match_user
546                 AND usp.org_unit IN ( SELECT * FROM unnest(context_org_list) )
547                 AND (usp.stop_date IS NULL or usp.stop_date > NOW())
548                 AND (csp.ignore_proximity IS NULL
549                      OR csp.ignore_proximity < home_prox
550                      OR csp.ignore_proximity < item_prox)
551                 AND csp.block_list LIKE penalty_type LOOP
552
553         result.fail_part := standing_penalty.name;
554         result.success := FALSE;
555         done := TRUE;
556         RETURN NEXT result;
557     END LOOP;
558
559     -- Fail if the test is set to hard non-circulating
560     IF circ_matchpoint.circulate IS FALSE THEN
561         result.fail_part := 'config.circ_matrix_test.circulate';
562         result.success := FALSE;
563         done := TRUE;
564         RETURN NEXT result;
565     END IF;
566
567     -- Fail if the total copy-hold ratio is too low
568     IF circ_matchpoint.total_copy_hold_ratio IS NOT NULL THEN
569         SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
570         IF hold_ratio.total_copy_ratio IS NOT NULL AND hold_ratio.total_copy_ratio < circ_matchpoint.total_copy_hold_ratio THEN
571             result.fail_part := 'config.circ_matrix_test.total_copy_hold_ratio';
572             result.success := FALSE;
573             done := TRUE;
574             RETURN NEXT result;
575         END IF;
576     END IF;
577
578     -- Fail if the available copy-hold ratio is too low
579     IF circ_matchpoint.available_copy_hold_ratio IS NOT NULL THEN
580         IF hold_ratio.hold_count IS NULL THEN
581             SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
582         END IF;
583         IF hold_ratio.available_copy_ratio IS NOT NULL AND hold_ratio.available_copy_ratio < circ_matchpoint.available_copy_hold_ratio THEN
584             result.fail_part := 'config.circ_matrix_test.available_copy_hold_ratio';
585             result.success := FALSE;
586             done := TRUE;
587             RETURN NEXT result;
588         END IF;
589     END IF;
590
591     -- Fail if the user has too many items out by defined limit sets
592     FOR circ_limit_set IN SELECT ccls.* FROM config.circ_limit_set ccls
593       JOIN config.circ_matrix_limit_set_map ccmlsm ON ccmlsm.limit_set = ccls.id
594       WHERE ccmlsm.active AND ( ccmlsm.matchpoint = circ_matchpoint.id OR
595         ( ccmlsm.matchpoint IN (SELECT * FROM unnest(result.buildrows)) AND ccmlsm.fallthrough )
596         ) LOOP
597             IF circ_limit_set.items_out > 0 AND NOT renewal THEN
598                 SELECT INTO context_org_list ARRAY_AGG(aou.id)
599                   FROM actor.org_unit_full_path( circ_ou ) aou
600                     JOIN actor.org_unit_type aout ON aou.ou_type = aout.id
601                   WHERE aout.depth >= circ_limit_set.depth;
602                 IF circ_limit_set.global THEN
603                     WITH RECURSIVE descendant_depth AS (
604                         SELECT  ou.id,
605                             ou.parent_ou
606                         FROM  actor.org_unit ou
607                         WHERE ou.id IN (SELECT * FROM unnest(context_org_list))
608                             UNION
609                         SELECT  ou.id,
610                             ou.parent_ou
611                         FROM  actor.org_unit ou
612                             JOIN descendant_depth ot ON (ot.id = ou.parent_ou)
613                     ) SELECT INTO context_org_list ARRAY_AGG(ou.id) FROM actor.org_unit ou JOIN descendant_depth USING (id);
614                 END IF;
615                 SELECT INTO items_out COUNT(DISTINCT circ.id)
616                   FROM action.circulation circ
617                     JOIN asset.copy copy ON (copy.id = circ.target_copy)
618                     LEFT JOIN action.circulation_limit_group_map aclgm ON (circ.id = aclgm.circ)
619                   WHERE circ.usr = match_user
620                     AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
621                     AND circ.checkin_time IS NULL
622                     AND (circ.stop_fines IN ('MAXFINES','LONGOVERDUE') OR circ.stop_fines IS NULL)
623                     AND (copy.circ_modifier IN (SELECT circ_mod FROM config.circ_limit_set_circ_mod_map WHERE limit_set = circ_limit_set.id)
624                         OR copy.location IN (SELECT copy_loc FROM config.circ_limit_set_copy_loc_map WHERE limit_set = circ_limit_set.id)
625                         OR aclgm.limit_group IN (SELECT limit_group FROM config.circ_limit_set_group_map WHERE limit_set = circ_limit_set.id)
626                     );
627                 IF items_out >= circ_limit_set.items_out THEN
628                     result.fail_part := 'config.circ_matrix_circ_mod_test';
629                     result.success := FALSE;
630                     done := TRUE;
631                     RETURN NEXT result;
632                 END IF;
633             END IF;
634             SELECT INTO result.limit_groups result.limit_groups || ARRAY_AGG(limit_group) FROM config.circ_limit_set_group_map WHERE limit_set = circ_limit_set.id AND NOT check_only;
635     END LOOP;
636
637     -- If we passed everything, return the successful matchpoint
638     IF NOT done THEN
639         RETURN NEXT result;
640     END IF;
641
642     RETURN;
643 END;
644 $func$ LANGUAGE plpgsql;
645
646 CREATE OR REPLACE FUNCTION action.item_user_circ_test( INT, BIGINT, INT ) RETURNS SETOF action.circ_matrix_test_result AS $func$
647     SELECT * FROM action.item_user_circ_test( $1, $2, $3, FALSE );
648 $func$ LANGUAGE SQL;
649
650 CREATE OR REPLACE FUNCTION action.item_user_renew_test( INT, BIGINT, INT ) RETURNS SETOF action.circ_matrix_test_result AS $func$
651     SELECT * FROM action.item_user_circ_test( $1, $2, $3, TRUE );
652 $func$ LANGUAGE SQL;
653
654 CREATE OR REPLACE FUNCTION actor.calculate_system_penalties( match_user INT, context_org INT ) RETURNS SETOF actor.usr_standing_penalty AS $func$
655 DECLARE
656     user_object         actor.usr%ROWTYPE;
657     new_sp_row          actor.usr_standing_penalty%ROWTYPE;
658     existing_sp_row     actor.usr_standing_penalty%ROWTYPE;
659     collections_fines   permission.grp_penalty_threshold%ROWTYPE;
660     max_fines           permission.grp_penalty_threshold%ROWTYPE;
661     max_overdue         permission.grp_penalty_threshold%ROWTYPE;
662     max_items_out       permission.grp_penalty_threshold%ROWTYPE;
663     max_lost            permission.grp_penalty_threshold%ROWTYPE;
664     max_longoverdue     permission.grp_penalty_threshold%ROWTYPE;
665     tmp_grp             INT;
666     items_overdue       INT;
667     items_out           INT;
668     items_lost          INT;
669     items_longoverdue   INT;
670     context_org_list    INT[];
671     current_fines        NUMERIC(8,2) := 0.0;
672     tmp_fines            NUMERIC(8,2);
673     tmp_groc            RECORD;
674     tmp_circ            RECORD;
675     tmp_org             actor.org_unit%ROWTYPE;
676     tmp_penalty         config.standing_penalty%ROWTYPE;
677     tmp_depth           INTEGER;
678 BEGIN
679     SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
680
681     -- Max fines
682     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
683
684     -- Fail if the user has a high fine balance
685     LOOP
686         tmp_grp := user_object.profile;
687         LOOP
688             SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 1 AND org_unit = tmp_org.id;
689
690             IF max_fines.threshold IS NULL THEN
691                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
692             ELSE
693                 EXIT;
694             END IF;
695
696             IF tmp_grp IS NULL THEN
697                 EXIT;
698             END IF;
699         END LOOP;
700
701         IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
702             EXIT;
703         END IF;
704
705         SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
706
707     END LOOP;
708
709     IF max_fines.threshold IS NOT NULL THEN
710
711         RETURN QUERY
712             SELECT  *
713               FROM  actor.usr_standing_penalty
714               WHERE usr = match_user
715                     AND org_unit = max_fines.org_unit
716                     AND (stop_date IS NULL or stop_date > NOW())
717                     AND standing_penalty = 1;
718
719         SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( max_fines.org_unit );
720
721         SELECT  SUM(f.balance_owed) INTO current_fines
722           FROM  money.materialized_billable_xact_summary f
723                 JOIN (
724                     SELECT  r.id
725                       FROM  booking.reservation r
726                       WHERE r.usr = match_user
727                             AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
728                             AND xact_finish IS NULL
729                                 UNION ALL
730                     SELECT  g.id
731                       FROM  money.grocery g
732                       WHERE g.usr = match_user
733                             AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
734                             AND xact_finish IS NULL
735                                 UNION ALL
736                     SELECT  circ.id
737                       FROM  action.circulation circ
738                       WHERE circ.usr = match_user
739                             AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
740                             AND xact_finish IS NULL ) l USING (id);
741
742         IF current_fines >= max_fines.threshold THEN
743             new_sp_row.usr := match_user;
744             new_sp_row.org_unit := max_fines.org_unit;
745             new_sp_row.standing_penalty := 1;
746             RETURN NEXT new_sp_row;
747         END IF;
748     END IF;
749
750     -- Start over for max overdue
751     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
752
753     -- Fail if the user has too many overdue items
754     LOOP
755         tmp_grp := user_object.profile;
756         LOOP
757
758             SELECT * INTO max_overdue FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 2 AND org_unit = tmp_org.id;
759
760             IF max_overdue.threshold IS NULL THEN
761                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
762             ELSE
763                 EXIT;
764             END IF;
765
766             IF tmp_grp IS NULL THEN
767                 EXIT;
768             END IF;
769         END LOOP;
770
771         IF max_overdue.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
772             EXIT;
773         END IF;
774
775         SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
776
777     END LOOP;
778
779     IF max_overdue.threshold IS NOT NULL THEN
780
781         RETURN QUERY
782             SELECT  *
783               FROM  actor.usr_standing_penalty
784               WHERE usr = match_user
785                     AND org_unit = max_overdue.org_unit
786                     AND (stop_date IS NULL or stop_date > NOW())
787                     AND standing_penalty = 2;
788
789         SELECT  INTO items_overdue COUNT(*)
790           FROM  action.circulation circ
791                 JOIN  actor.org_unit_full_path( max_overdue.org_unit ) fp ON (circ.circ_lib = fp.id)
792           WHERE circ.usr = match_user
793             AND circ.checkin_time IS NULL
794             AND circ.due_date < NOW()
795             AND (circ.stop_fines = 'MAXFINES' OR circ.stop_fines IS NULL);
796
797         IF items_overdue >= max_overdue.threshold::INT THEN
798             new_sp_row.usr := match_user;
799             new_sp_row.org_unit := max_overdue.org_unit;
800             new_sp_row.standing_penalty := 2;
801             RETURN NEXT new_sp_row;
802         END IF;
803     END IF;
804
805     -- Start over for max out
806     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
807
808     -- Fail if the user has too many checked out items
809     LOOP
810         tmp_grp := user_object.profile;
811         LOOP
812             SELECT * INTO max_items_out FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 3 AND org_unit = tmp_org.id;
813
814             IF max_items_out.threshold IS NULL THEN
815                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
816             ELSE
817                 EXIT;
818             END IF;
819
820             IF tmp_grp IS NULL THEN
821                 EXIT;
822             END IF;
823         END LOOP;
824
825         IF max_items_out.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
826             EXIT;
827         END IF;
828
829         SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
830
831     END LOOP;
832
833
834     -- Fail if the user has too many items checked out
835     IF max_items_out.threshold IS NOT NULL THEN
836
837         RETURN QUERY
838             SELECT  *
839               FROM  actor.usr_standing_penalty
840               WHERE usr = match_user
841                     AND org_unit = max_items_out.org_unit
842                     AND (stop_date IS NULL or stop_date > NOW())
843                     AND standing_penalty = 3;
844
845         SELECT  INTO items_out COUNT(*)
846           FROM  action.circulation circ
847                 JOIN  actor.org_unit_full_path( max_items_out.org_unit ) fp ON (circ.circ_lib = fp.id)
848           WHERE circ.usr = match_user
849                 AND circ.checkin_time IS NULL
850                 AND (circ.stop_fines IN (
851                     SELECT 'MAXFINES'::TEXT
852                     UNION ALL
853                     SELECT 'LONGOVERDUE'::TEXT
854                     UNION ALL
855                     SELECT 'LOST'::TEXT
856                     WHERE 'true' ILIKE
857                     (
858                         SELECT CASE
859                             WHEN (SELECT value FROM actor.org_unit_ancestor_setting('circ.tally_lost', circ.circ_lib)) ILIKE 'true' THEN 'true'
860                             ELSE 'false'
861                         END
862                     )
863                     UNION ALL
864                     SELECT 'CLAIMSRETURNED'::TEXT
865                     WHERE 'false' ILIKE
866                     (
867                         SELECT CASE
868                             WHEN (SELECT value FROM actor.org_unit_ancestor_setting('circ.do_not_tally_claims_returned', circ.circ_lib)) ILIKE 'true' THEN 'true'
869                             ELSE 'false'
870                         END
871                     )
872                     ) OR circ.stop_fines IS NULL)
873                 AND xact_finish IS NULL;
874
875            IF items_out >= max_items_out.threshold::INT THEN
876             new_sp_row.usr := match_user;
877             new_sp_row.org_unit := max_items_out.org_unit;
878             new_sp_row.standing_penalty := 3;
879             RETURN NEXT new_sp_row;
880            END IF;
881     END IF;
882
883     -- Start over for max lost
884     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
885
886     -- Fail if the user has too many lost items
887     LOOP
888         tmp_grp := user_object.profile;
889         LOOP
890
891             SELECT * INTO max_lost FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 5 AND org_unit = tmp_org.id;
892
893             IF max_lost.threshold IS NULL THEN
894                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
895             ELSE
896                 EXIT;
897             END IF;
898
899             IF tmp_grp IS NULL THEN
900                 EXIT;
901             END IF;
902         END LOOP;
903
904         IF max_lost.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
905             EXIT;
906         END IF;
907
908         SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
909
910     END LOOP;
911
912     IF max_lost.threshold IS NOT NULL THEN
913
914         RETURN QUERY
915             SELECT  *
916             FROM  actor.usr_standing_penalty
917             WHERE usr = match_user
918                 AND org_unit = max_lost.org_unit
919                 AND (stop_date IS NULL or stop_date > NOW())
920                 AND standing_penalty = 5;
921
922         SELECT  INTO items_lost COUNT(*)
923         FROM  action.circulation circ
924             JOIN  actor.org_unit_full_path( max_lost.org_unit ) fp ON (circ.circ_lib = fp.id)
925         WHERE circ.usr = match_user
926             AND circ.checkin_time IS NULL
927             AND (circ.stop_fines = 'LOST')
928             AND xact_finish IS NULL;
929
930         IF items_lost >= max_lost.threshold::INT AND 0 < max_lost.threshold::INT THEN
931             new_sp_row.usr := match_user;
932             new_sp_row.org_unit := max_lost.org_unit;
933             new_sp_row.standing_penalty := 5;
934             RETURN NEXT new_sp_row;
935         END IF;
936     END IF;
937
938     -- Start over for max longoverdue
939     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
940
941     -- Fail if the user has too many longoverdue items
942     LOOP
943         tmp_grp := user_object.profile;
944         LOOP
945
946             SELECT * INTO max_longoverdue 
947                 FROM permission.grp_penalty_threshold 
948                 WHERE grp = tmp_grp AND 
949                     penalty = 35 AND 
950                     org_unit = tmp_org.id;
951
952             IF max_longoverdue.threshold IS NULL THEN
953                 SELECT parent INTO tmp_grp 
954                     FROM permission.grp_tree WHERE id = tmp_grp;
955             ELSE
956                 EXIT;
957             END IF;
958
959             IF tmp_grp IS NULL THEN
960                 EXIT;
961             END IF;
962         END LOOP;
963
964         IF max_longoverdue.threshold IS NOT NULL 
965                 OR tmp_org.parent_ou IS NULL THEN
966             EXIT;
967         END IF;
968
969         SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
970
971     END LOOP;
972
973     IF max_longoverdue.threshold IS NOT NULL THEN
974
975         RETURN QUERY
976             SELECT  *
977             FROM  actor.usr_standing_penalty
978             WHERE usr = match_user
979                 AND org_unit = max_longoverdue.org_unit
980                 AND (stop_date IS NULL or stop_date > NOW())
981                 AND standing_penalty = 35;
982
983         SELECT INTO items_longoverdue COUNT(*)
984         FROM action.circulation circ
985             JOIN actor.org_unit_full_path( max_longoverdue.org_unit ) fp 
986                 ON (circ.circ_lib = fp.id)
987         WHERE circ.usr = match_user
988             AND circ.checkin_time IS NULL
989             AND (circ.stop_fines = 'LONGOVERDUE')
990             AND xact_finish IS NULL;
991
992         IF items_longoverdue >= max_longoverdue.threshold::INT 
993                 AND 0 < max_longoverdue.threshold::INT THEN
994             new_sp_row.usr := match_user;
995             new_sp_row.org_unit := max_longoverdue.org_unit;
996             new_sp_row.standing_penalty := 35;
997             RETURN NEXT new_sp_row;
998         END IF;
999     END IF;
1000
1001
1002     -- Start over for collections warning
1003     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
1004
1005     -- Fail if the user has a collections-level fine balance
1006     LOOP
1007         tmp_grp := user_object.profile;
1008         LOOP
1009             SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 4 AND org_unit = tmp_org.id;
1010
1011             IF max_fines.threshold IS NULL THEN
1012                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
1013             ELSE
1014                 EXIT;
1015             END IF;
1016
1017             IF tmp_grp IS NULL THEN
1018                 EXIT;
1019             END IF;
1020         END LOOP;
1021
1022         IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
1023             EXIT;
1024         END IF;
1025
1026         SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
1027
1028     END LOOP;
1029
1030     IF max_fines.threshold IS NOT NULL THEN
1031
1032         RETURN QUERY
1033             SELECT  *
1034               FROM  actor.usr_standing_penalty
1035               WHERE usr = match_user
1036                     AND org_unit = max_fines.org_unit
1037                     AND (stop_date IS NULL or stop_date > NOW())
1038                     AND standing_penalty = 4;
1039
1040         SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( max_fines.org_unit );
1041
1042         SELECT  SUM(f.balance_owed) INTO current_fines
1043           FROM  money.materialized_billable_xact_summary f
1044                 JOIN (
1045                     SELECT  r.id
1046                       FROM  booking.reservation r
1047                       WHERE r.usr = match_user
1048                             AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
1049                             AND r.xact_finish IS NULL
1050                                 UNION ALL
1051                     SELECT  g.id
1052                       FROM  money.grocery g
1053                       WHERE g.usr = match_user
1054                             AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
1055                             AND g.xact_finish IS NULL
1056                                 UNION ALL
1057                     SELECT  circ.id
1058                       FROM  action.circulation circ
1059                       WHERE circ.usr = match_user
1060                             AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
1061                             AND circ.xact_finish IS NULL ) l USING (id);
1062
1063         IF current_fines >= max_fines.threshold THEN
1064             new_sp_row.usr := match_user;
1065             new_sp_row.org_unit := max_fines.org_unit;
1066             new_sp_row.standing_penalty := 4;
1067             RETURN NEXT new_sp_row;
1068         END IF;
1069     END IF;
1070
1071     -- Start over for in collections
1072     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
1073
1074     -- Remove the in-collections penalty if the user has paid down enough
1075     -- This penalty is different, because this code is not responsible for creating 
1076     -- new in-collections penalties, only for removing them
1077     LOOP
1078         tmp_grp := user_object.profile;
1079         LOOP
1080             SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 30 AND org_unit = tmp_org.id;
1081
1082             IF max_fines.threshold IS NULL THEN
1083                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
1084             ELSE
1085                 EXIT;
1086             END IF;
1087
1088             IF tmp_grp IS NULL THEN
1089                 EXIT;
1090             END IF;
1091         END LOOP;
1092
1093         IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
1094             EXIT;
1095         END IF;
1096
1097         SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
1098
1099     END LOOP;
1100
1101     IF max_fines.threshold IS NOT NULL THEN
1102
1103         SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( max_fines.org_unit );
1104
1105         -- first, see if the user had paid down to the threshold
1106         SELECT  SUM(f.balance_owed) INTO current_fines
1107           FROM  money.materialized_billable_xact_summary f
1108                 JOIN (
1109                     SELECT  r.id
1110                       FROM  booking.reservation r
1111                       WHERE r.usr = match_user
1112                             AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
1113                             AND r.xact_finish IS NULL
1114                                 UNION ALL
1115                     SELECT  g.id
1116                       FROM  money.grocery g
1117                       WHERE g.usr = match_user
1118                             AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
1119                             AND g.xact_finish IS NULL
1120                                 UNION ALL
1121                     SELECT  circ.id
1122                       FROM  action.circulation circ
1123                       WHERE circ.usr = match_user
1124                             AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
1125                             AND circ.xact_finish IS NULL ) l USING (id);
1126
1127         IF current_fines IS NULL OR current_fines <= max_fines.threshold THEN
1128             -- patron has paid down enough
1129
1130             SELECT INTO tmp_penalty * FROM config.standing_penalty WHERE id = 30;
1131
1132             IF tmp_penalty.org_depth IS NOT NULL THEN
1133
1134                 -- since this code is not responsible for applying the penalty, it can't 
1135                 -- guarantee the current context org will match the org at which the penalty 
1136                 --- was applied.  search up the org tree until we hit the configured penalty depth
1137                 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
1138                 SELECT INTO tmp_depth depth FROM actor.org_unit_type WHERE id = tmp_org.ou_type;
1139
1140                 WHILE tmp_depth >= tmp_penalty.org_depth LOOP
1141
1142                     RETURN QUERY
1143                         SELECT  *
1144                           FROM  actor.usr_standing_penalty
1145                           WHERE usr = match_user
1146                                 AND org_unit = tmp_org.id
1147                                 AND (stop_date IS NULL or stop_date > NOW())
1148                                 AND standing_penalty = 30;
1149
1150                     IF tmp_org.parent_ou IS NULL THEN
1151                         EXIT;
1152                     END IF;
1153
1154                     SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
1155                     SELECT INTO tmp_depth depth FROM actor.org_unit_type WHERE id = tmp_org.ou_type;
1156                 END LOOP;
1157
1158             ELSE
1159
1160                 -- no penalty depth is defined, look for exact matches
1161
1162                 RETURN QUERY
1163                     SELECT  *
1164                       FROM  actor.usr_standing_penalty
1165                       WHERE usr = match_user
1166                             AND org_unit = max_fines.org_unit
1167                             AND (stop_date IS NULL or stop_date > NOW())
1168                             AND standing_penalty = 30;
1169             END IF;
1170     
1171         END IF;
1172
1173     END IF;
1174
1175     RETURN;
1176 END;
1177 $func$ LANGUAGE plpgsql;
1178
1179
1180
1181 COMMIT;
1182