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