]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/sql/Pg/100.circ_matrix.sql
Merge Dan Wells' changes to the serial schema from the
[working/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 -- config table to hold the vr_format names
45 CREATE TABLE config.videorecording_format_map (
46     code    TEXT    PRIMARY KEY,
47     value    TEXT    NOT NULL
48 );
49
50 INSERT INTO config.videorecording_format_map VALUES ('a','Beta');
51 INSERT INTO config.videorecording_format_map VALUES ('b','VHS');
52 INSERT INTO config.videorecording_format_map VALUES ('c','U-matic');
53 INSERT INTO config.videorecording_format_map VALUES ('d','EIAJ');
54 INSERT INTO config.videorecording_format_map VALUES ('e','Type C');
55 INSERT INTO config.videorecording_format_map VALUES ('f','Quadruplex');
56 INSERT INTO config.videorecording_format_map VALUES ('g','Laserdisc');
57 INSERT INTO config.videorecording_format_map VALUES ('h','CED');
58 INSERT INTO config.videorecording_format_map VALUES ('i','Betacam');
59 INSERT INTO config.videorecording_format_map VALUES ('j','Betacam SP');
60 INSERT INTO config.videorecording_format_map VALUES ('k','Super-VHS');
61 INSERT INTO config.videorecording_format_map VALUES ('m','M-II');
62 INSERT INTO config.videorecording_format_map VALUES ('o','D-2');
63 INSERT INTO config.videorecording_format_map VALUES ('p','8 mm.');
64 INSERT INTO config.videorecording_format_map VALUES ('q','Hi-8 mm.');
65 INSERT INTO config.videorecording_format_map VALUES ('u','Unknown');
66 INSERT INTO config.videorecording_format_map VALUES ('v','DVD');
67 INSERT INTO config.videorecording_format_map VALUES ('z','Other');
68
69
70
71 /**
72  **  Here we define the tables that make up the circ matrix.  Conceptually, this implements
73  **  the "sparse matrix" that everyone talks about, instead of using traditional rules logic.
74  **  Physically, we cut the matrix up into separate tables (almost 3rd normal form!) that handle
75  **  different portions of the matrix.  This wil simplify creation of the UI (I hope), and help the
76  **  developers focus on specific parts of the matrix.
77  **/
78
79
80 --
81 --                 ****** Which ruleset and tests to use *******
82 --
83 -- * Most specific range for org_unit and grp wins.
84 --
85 -- * circ_modifier match takes precidence over marc_type match, if circ_modifier is set here
86 --
87 -- * marc_type is first checked against the circ_as_type from the copy, then the item type from the marc record
88 --
89 -- * If neither circ_modifier nor marc_type is set (both are NULLABLE) then the entry defines the default
90 --   ruleset and tests for the OU + group (like BOOK in PINES)
91 --
92
93 CREATE TABLE config.circ_matrix_matchpoint (
94     id                   SERIAL    PRIMARY KEY,
95     active               BOOL    NOT NULL DEFAULT TRUE,
96     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"
97     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
98     circ_modifier        TEXT    REFERENCES config.circ_modifier (code) DEFERRABLE INITIALLY DEFERRED,
99     marc_type            TEXT    REFERENCES config.item_type_map (code) DEFERRABLE INITIALLY DEFERRED,
100     marc_form            TEXT    REFERENCES config.item_form_map (code) DEFERRABLE INITIALLY DEFERRED,
101     marc_vr_format       TEXT    REFERENCES config.videorecording_format_map (code) DEFERRABLE INITIALLY DEFERRED,
102     copy_circ_lib        INT     REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
103     copy_owning_lib      INT     REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
104     ref_flag             BOOL,
105     juvenile_flag        BOOL,
106     is_renewal           BOOL,
107     usr_age_lower_bound  INTERVAL,
108     usr_age_upper_bound  INTERVAL,
109     circulate            BOOL    NOT NULL DEFAULT TRUE,    -- Hard "can't circ" flag requiring an override
110     duration_rule        INT     NOT NULL REFERENCES config.rule_circ_duration (id) DEFERRABLE INITIALLY DEFERRED,
111     recurring_fine_rule  INT     NOT NULL REFERENCES config.rule_recurring_fine (id) DEFERRABLE INITIALLY DEFERRED,
112     max_fine_rule        INT     NOT NULL REFERENCES config.rule_max_fine (id) DEFERRABLE INITIALLY DEFERRED,
113     script_test          TEXT,                           -- javascript source 
114     total_copy_hold_ratio     FLOAT,
115     available_copy_hold_ratio FLOAT,
116     CONSTRAINT ep_once_per_grp_loc_mod_marc UNIQUE (
117         grp, org_unit, circ_modifier, marc_type, marc_form, marc_vr_format, ref_flag,
118         juvenile_flag, usr_age_lower_bound, usr_age_upper_bound, is_renewal, copy_circ_lib,
119         copy_owning_lib
120     )
121 );
122
123
124 -- Tests for max items out by circ_modifier
125 CREATE TABLE config.circ_matrix_circ_mod_test (
126     id          SERIAL     PRIMARY KEY,
127     matchpoint  INT     NOT NULL REFERENCES config.circ_matrix_matchpoint (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
128     items_out   INT     NOT NULL -- Total current active circulations must be less than this, NULL means skip (always pass)
129 );
130
131 CREATE TABLE config.circ_matrix_circ_mod_test_map (
132     id      SERIAL  PRIMARY KEY,
133     circ_mod_test   INT NOT NULL REFERENCES config.circ_matrix_circ_mod_test (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
134     circ_mod        TEXT    NOT NULL REFERENCES config.circ_modifier (code) ON DELETE CASCADE ON UPDATE CASCADE  DEFERRABLE INITIALLY DEFERRED,
135     CONSTRAINT cm_once_per_test UNIQUE (circ_mod_test, circ_mod)
136 );
137
138 CREATE OR REPLACE FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS config.circ_matrix_matchpoint AS $func$
139 DECLARE
140     current_group    permission.grp_tree%ROWTYPE;
141     user_object    actor.usr%ROWTYPE;
142     item_object    asset.copy%ROWTYPE;
143     cn_object    asset.call_number%ROWTYPE;
144     rec_descriptor    metabib.rec_descriptor%ROWTYPE;
145     current_mp    config.circ_matrix_matchpoint%ROWTYPE;
146     matchpoint    config.circ_matrix_matchpoint%ROWTYPE;
147 BEGIN
148     SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
149     SELECT INTO item_object * FROM asset.copy WHERE id = match_item;
150     SELECT INTO cn_object * FROM asset.call_number WHERE id = item_object.call_number;
151     SELECT INTO rec_descriptor r.* FROM metabib.rec_descriptor r JOIN asset.call_number c USING (record) WHERE c.id = item_object.call_number;
152     SELECT INTO current_group * FROM permission.grp_tree WHERE id = user_object.profile;
153
154     LOOP 
155         -- for each potential matchpoint for this ou and group ...
156         FOR current_mp IN
157             SELECT  m.*
158               FROM  config.circ_matrix_matchpoint m
159                     JOIN actor.org_unit_ancestors( context_ou ) d ON (m.org_unit = d.id)
160                     LEFT JOIN actor.org_unit_proximity p ON (p.from_org = context_ou AND p.to_org = d.id)
161               WHERE m.grp = current_group.id
162                     AND m.active
163                     AND (m.copy_owning_lib IS NULL OR cn_object.owning_lib IN ( SELECT id FROM actor.org_unit_descendants(m.copy_owning_lib) ))
164                     AND (m.copy_circ_lib   IS NULL OR item_object.circ_lib IN ( SELECT id FROM actor.org_unit_descendants(m.copy_circ_lib)   ))
165               ORDER BY    CASE WHEN p.prox        IS NULL THEN 999 ELSE p.prox END,
166                     CASE WHEN m.copy_owning_lib IS NOT NULL
167                         THEN 256 / ( SELECT COALESCE(prox, 255) + 1 FROM actor.org_unit_proximity WHERE to_org = cn_object.owning_lib AND from_org = m.copy_owning_lib LIMIT 1 )
168                         ELSE 0
169                     END +
170                     CASE WHEN m.copy_circ_lib IS NOT NULL
171                         THEN 256 / ( SELECT COALESCE(prox, 255) + 1 FROM actor.org_unit_proximity WHERE to_org = item_object.circ_lib AND from_org = m.copy_circ_lib LIMIT 1 )
172                         ELSE 0
173                     END +
174                     CASE WHEN m.is_renewal = renewal        THEN 128 ELSE 0 END +
175                     CASE WHEN m.juvenile_flag    IS NOT NULL THEN 64 ELSE 0 END +
176                     CASE WHEN m.circ_modifier    IS NOT NULL THEN 32 ELSE 0 END +
177                     CASE WHEN m.marc_type        IS NOT NULL THEN 16 ELSE 0 END +
178                     CASE WHEN m.marc_form        IS NOT NULL THEN 8 ELSE 0 END +
179                     CASE WHEN m.marc_vr_format    IS NOT NULL THEN 4 ELSE 0 END +
180                     CASE WHEN m.ref_flag        IS NOT NULL THEN 2 ELSE 0 END +
181                     CASE WHEN m.usr_age_lower_bound    IS NOT NULL THEN 0.5 ELSE 0 END +
182                     CASE WHEN m.usr_age_upper_bound    IS NOT NULL THEN 0.5 ELSE 0 END DESC LOOP
183
184             IF current_mp.circ_modifier IS NOT NULL THEN
185                 CONTINUE WHEN current_mp.circ_modifier <> item_object.circ_modifier OR item_object.circ_modifier IS NULL;
186             END IF;
187
188             IF current_mp.marc_type IS NOT NULL THEN
189                 IF item_object.circ_as_type IS NOT NULL THEN
190                     CONTINUE WHEN current_mp.marc_type <> item_object.circ_as_type;
191                 ELSE
192                     CONTINUE WHEN current_mp.marc_type <> rec_descriptor.item_type;
193                 END IF;
194             END IF;
195
196             IF current_mp.marc_form IS NOT NULL THEN
197                 CONTINUE WHEN current_mp.marc_form <> rec_descriptor.item_form;
198             END IF;
199
200             IF current_mp.marc_vr_format IS NOT NULL THEN
201                 CONTINUE WHEN current_mp.marc_vr_format <> rec_descriptor.vr_format;
202             END IF;
203
204             IF current_mp.ref_flag IS NOT NULL THEN
205                 CONTINUE WHEN current_mp.ref_flag <> item_object.ref;
206             END IF;
207
208             IF current_mp.juvenile_flag IS NOT NULL THEN
209                 CONTINUE WHEN current_mp.juvenile_flag <> user_object.juvenile;
210             END IF;
211
212             IF current_mp.usr_age_lower_bound IS NOT NULL THEN
213                 CONTINUE WHEN user_object.dob IS NULL OR current_mp.usr_age_lower_bound < age(user_object.dob);
214             END IF;
215
216             IF current_mp.usr_age_upper_bound IS NOT NULL THEN
217                 CONTINUE WHEN user_object.dob IS NULL OR current_mp.usr_age_upper_bound > age(user_object.dob);
218             END IF;
219
220
221             -- everything was undefined or matched
222             matchpoint = current_mp;
223
224             EXIT WHEN matchpoint.id IS NOT NULL;
225         END LOOP;
226
227         EXIT WHEN current_group.parent IS NULL OR matchpoint.id IS NOT NULL;
228
229         SELECT INTO current_group * FROM permission.grp_tree WHERE id = current_group.parent;
230     END LOOP;
231
232     RETURN matchpoint;
233 END;
234 $func$ LANGUAGE plpgsql;
235
236 CREATE TYPE action.hold_stats AS (
237     hold_count              INT,
238     copy_count              INT,
239     available_count         INT,
240     total_copy_ratio        FLOAT,
241     available_copy_ratio    FLOAT
242 );
243
244 CREATE OR REPLACE FUNCTION action.copy_related_hold_stats (copy_id INT) RETURNS action.hold_stats AS $func$
245 DECLARE
246     output          action.hold_stats%ROWTYPE;
247     hold_count      INT := 0;
248     copy_count      INT := 0;
249     available_count INT := 0;
250     hold_map_data   RECORD;
251 BEGIN
252
253     output.hold_count := 0;
254     output.copy_count := 0;
255     output.available_count := 0;
256
257     SELECT  COUNT( DISTINCT m.hold ) INTO hold_count
258       FROM  action.hold_copy_map m
259             JOIN action.hold_request h ON (m.hold = h.id)
260       WHERE m.target_copy = copy_id
261             AND NOT h.frozen;
262
263     output.hold_count := hold_count;
264
265     IF output.hold_count > 0 THEN
266         FOR hold_map_data IN
267             SELECT  DISTINCT m.target_copy,
268                     acp.status
269               FROM  action.hold_copy_map m
270                     JOIN asset.copy acp ON (m.target_copy = acp.id)
271                     JOIN action.hold_request h ON (m.hold = h.id)
272               WHERE m.hold IN ( SELECT DISTINCT hold FROM action.hold_copy_map WHERE target_copy = copy_id ) AND NOT h.frozen
273         LOOP
274             output.copy_count := output.copy_count + 1;
275             IF hold_map_data.status IN (0,7,12) THEN
276                 output.available_count := output.available_count + 1;
277             END IF;
278         END LOOP;
279         output.total_copy_ratio = output.copy_count::FLOAT / output.hold_count::FLOAT;
280         output.available_copy_ratio = output.available_count::FLOAT / output.hold_count::FLOAT;
281
282     END IF;
283
284     RETURN output;
285
286 END;
287 $func$ LANGUAGE PLPGSQL;
288
289 CREATE TYPE action.matrix_test_result AS ( success BOOL, matchpoint INT, fail_part TEXT );
290 CREATE OR REPLACE FUNCTION action.item_user_circ_test( circ_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.matrix_test_result AS $func$
291 DECLARE
292     user_object        actor.usr%ROWTYPE;
293     standing_penalty    config.standing_penalty%ROWTYPE;
294     item_object        asset.copy%ROWTYPE;
295     item_status_object    config.copy_status%ROWTYPE;
296     item_location_object    asset.copy_location%ROWTYPE;
297     result            action.matrix_test_result;
298     circ_test        config.circ_matrix_matchpoint%ROWTYPE;
299     out_by_circ_mod        config.circ_matrix_circ_mod_test%ROWTYPE;
300     circ_mod_map        config.circ_matrix_circ_mod_test_map%ROWTYPE;
301     hold_ratio          action.hold_stats%ROWTYPE;
302     penalty_type         TEXT;
303     tmp_grp         INT;
304     items_out        INT;
305     context_org_list        INT[];
306     done            BOOL := FALSE;
307 BEGIN
308     result.success := TRUE;
309
310     -- Fail if the user is BARRED
311     SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
312
313     -- Fail if we couldn't find the user 
314     IF user_object.id IS NULL THEN
315         result.fail_part := 'no_user';
316         result.success := FALSE;
317         done := TRUE;
318         RETURN NEXT result;
319         RETURN;
320     END IF;
321
322     SELECT INTO item_object * FROM asset.copy WHERE id = match_item;
323
324     -- Fail if we couldn't find the item 
325     IF item_object.id IS NULL THEN
326         result.fail_part := 'no_item';
327         result.success := FALSE;
328         done := TRUE;
329         RETURN NEXT result;
330         RETURN;
331     END IF;
332
333     SELECT INTO circ_test * FROM action.find_circ_matrix_matchpoint(circ_ou, match_item, match_user, renewal);
334     result.matchpoint := circ_test.id;
335
336     -- Fail if we couldn't find a matchpoint
337     IF result.matchpoint IS NULL THEN
338         result.fail_part := 'no_matchpoint';
339         result.success := FALSE;
340         done := TRUE;
341         RETURN NEXT result;
342     END IF;
343
344     IF user_object.barred IS TRUE THEN
345         result.fail_part := 'actor.usr.barred';
346         result.success := FALSE;
347         done := TRUE;
348         RETURN NEXT result;
349     END IF;
350
351     -- Fail if the item can't circulate
352     IF item_object.circulate IS FALSE THEN
353         result.fail_part := 'asset.copy.circulate';
354         result.success := FALSE;
355         done := TRUE;
356         RETURN NEXT result;
357     END IF;
358
359     -- Fail if the item isn't in a circulateable status on a non-renewal
360     IF NOT renewal AND item_object.status NOT IN ( 0, 7, 8 ) THEN 
361         result.fail_part := 'asset.copy.status';
362         result.success := FALSE;
363         done := TRUE;
364         RETURN NEXT result;
365     ELSIF renewal AND item_object.status <> 1 THEN
366         result.fail_part := 'asset.copy.status';
367         result.success := FALSE;
368         done := TRUE;
369         RETURN NEXT result;
370     END IF;
371
372     -- Fail if the item can't circulate because of the shelving location
373     SELECT INTO item_location_object * FROM asset.copy_location WHERE id = item_object.location;
374     IF item_location_object.circulate IS FALSE THEN
375         result.fail_part := 'asset.copy_location.circulate';
376         result.success := FALSE;
377         done := TRUE;
378         RETURN NEXT result;
379     END IF;
380
381     SELECT INTO context_org_list ARRAY_ACCUM(id) FROM actor.org_unit_full_path( circ_test.org_unit );
382
383     -- Fail if the test is set to hard non-circulating
384     IF circ_test.circulate IS FALSE THEN
385         result.fail_part := 'config.circ_matrix_test.circulate';
386         result.success := FALSE;
387         done := TRUE;
388         RETURN NEXT result;
389     END IF;
390
391     -- Fail if the total copy-hold ratio is too low
392     IF circ_test.total_copy_hold_ratio IS NOT NULL THEN
393         SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
394         IF hold_ratio.total_copy_ratio IS NOT NULL AND hold_ratio.total_copy_ratio < circ_test.total_copy_hold_ratio THEN
395             result.fail_part := 'config.circ_matrix_test.total_copy_hold_ratio';
396             result.success := FALSE;
397             done := TRUE;
398             RETURN NEXT result;
399         END IF;
400     END IF;
401
402     -- Fail if the available copy-hold ratio is too low
403     IF circ_test.available_copy_hold_ratio IS NOT NULL THEN
404         SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
405         IF hold_ratio.available_copy_ratio IS NOT NULL AND hold_ratio.available_copy_ratio < circ_test.available_copy_hold_ratio THEN
406             result.fail_part := 'config.circ_matrix_test.available_copy_hold_ratio';
407             result.success := FALSE;
408             done := TRUE;
409             RETURN NEXT result;
410         END IF;
411     END IF;
412
413     IF renewal THEN
414         penalty_type = '%RENEW%';
415     ELSE
416         penalty_type = '%CIRC%';
417     END IF;
418
419     FOR standing_penalty IN
420         SELECT  DISTINCT csp.*
421           FROM  actor.usr_standing_penalty usp
422                 JOIN config.standing_penalty csp ON (csp.id = usp.standing_penalty)
423           WHERE usr = match_user
424                 AND usp.org_unit IN ( SELECT * FROM explode_array(context_org_list) )
425                 AND (usp.stop_date IS NULL or usp.stop_date > NOW())
426                 AND csp.block_list LIKE penalty_type LOOP
427
428         result.fail_part := standing_penalty.name;
429         result.success := FALSE;
430         done := TRUE;
431         RETURN NEXT result;
432     END LOOP;
433
434     -- Fail if the user has too many items with specific circ_modifiers checked out
435     FOR out_by_circ_mod IN SELECT * FROM config.circ_matrix_circ_mod_test WHERE matchpoint = circ_test.id LOOP
436         SELECT  INTO items_out COUNT(*)
437           FROM  action.circulation circ
438             JOIN asset.copy cp ON (cp.id = circ.target_copy)
439           WHERE circ.usr = match_user
440                AND circ.circ_lib IN ( SELECT * FROM explode_array(context_org_list) )
441             AND circ.checkin_time IS NULL
442             AND (circ.stop_fines IN ('MAXFINES','LONGOVERDUE') OR circ.stop_fines IS NULL)
443             AND cp.circ_modifier IN (SELECT circ_mod FROM config.circ_matrix_circ_mod_test_map WHERE circ_mod_test = out_by_circ_mod.id);
444         IF items_out >= out_by_circ_mod.items_out THEN
445             result.fail_part := 'config.circ_matrix_circ_mod_test';
446             result.success := FALSE;
447             done := TRUE;
448             RETURN NEXT result;
449         END IF;
450     END LOOP;
451
452     -- If we passed everything, return the successful matchpoint id
453     IF NOT done THEN
454         RETURN NEXT result;
455     END IF;
456
457     RETURN;
458 END;
459 $func$ LANGUAGE plpgsql;
460
461 CREATE OR REPLACE FUNCTION action.item_user_circ_test( INT, BIGINT, INT ) RETURNS SETOF action.matrix_test_result AS $func$
462     SELECT * FROM action.item_user_circ_test( $1, $2, $3, FALSE );
463 $func$ LANGUAGE SQL;
464
465 CREATE OR REPLACE FUNCTION action.item_user_renew_test( INT, BIGINT, INT ) RETURNS SETOF action.matrix_test_result AS $func$
466     SELECT * FROM action.item_user_circ_test( $1, $2, $3, TRUE );
467 $func$ LANGUAGE SQL;
468
469
470 CREATE OR REPLACE FUNCTION actor.calculate_system_penalties( match_user INT, context_org INT ) RETURNS SETOF actor.usr_standing_penalty AS $func$
471 DECLARE
472     user_object         actor.usr%ROWTYPE;
473     new_sp_row          actor.usr_standing_penalty%ROWTYPE;
474     existing_sp_row     actor.usr_standing_penalty%ROWTYPE;
475     collections_fines   permission.grp_penalty_threshold%ROWTYPE;
476     max_fines           permission.grp_penalty_threshold%ROWTYPE;
477     max_overdue         permission.grp_penalty_threshold%ROWTYPE;
478     max_items_out       permission.grp_penalty_threshold%ROWTYPE;
479     tmp_grp             INT;
480     items_overdue       INT;
481     items_out           INT;
482     context_org_list    INT[];
483     current_fines        NUMERIC(8,2) := 0.0;
484     tmp_fines            NUMERIC(8,2);
485     tmp_groc            RECORD;
486     tmp_circ            RECORD;
487     tmp_org             actor.org_unit%ROWTYPE;
488     tmp_penalty         config.standing_penalty%ROWTYPE;
489     tmp_depth           INTEGER;
490 BEGIN
491     SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
492
493     -- Max fines
494     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
495
496     -- Fail if the user has a high fine balance
497     LOOP
498         tmp_grp := user_object.profile;
499         LOOP
500             SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 1 AND org_unit = tmp_org.id;
501
502             IF max_fines.threshold IS NULL THEN
503                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
504             ELSE
505                 EXIT;
506             END IF;
507
508             IF tmp_grp IS NULL THEN
509                 EXIT;
510             END IF;
511         END LOOP;
512
513         IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
514             EXIT;
515         END IF;
516
517         SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
518
519     END LOOP;
520
521     IF max_fines.threshold IS NOT NULL THEN
522
523         FOR existing_sp_row IN
524                 SELECT  *
525                   FROM  actor.usr_standing_penalty
526                   WHERE usr = match_user
527                         AND org_unit = max_fines.org_unit
528                         AND (stop_date IS NULL or stop_date > NOW())
529                         AND standing_penalty = 1
530                 LOOP
531             RETURN NEXT existing_sp_row;
532         END LOOP;
533
534         SELECT  SUM(f.balance_owed) INTO current_fines
535           FROM  money.materialized_billable_xact_summary f
536                 JOIN (
537                     SELECT  r.id
538                       FROM  booking.reservation r
539                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (r.pickup_lib = fp.id)
540                       WHERE usr = match_user
541                             AND xact_finish IS NULL
542                                 UNION ALL
543                     SELECT  g.id
544                       FROM  money.grocery g
545                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (g.billing_location = fp.id)
546                       WHERE usr = match_user
547                             AND xact_finish IS NULL
548                                 UNION ALL
549                     SELECT  circ.id
550                       FROM  action.circulation circ
551                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (circ.circ_lib = fp.id)
552                       WHERE usr = match_user
553                             AND xact_finish IS NULL ) l USING (id);
554
555         IF current_fines >= max_fines.threshold THEN
556             new_sp_row.usr := match_user;
557             new_sp_row.org_unit := max_fines.org_unit;
558             new_sp_row.standing_penalty := 1;
559             RETURN NEXT new_sp_row;
560         END IF;
561     END IF;
562
563     -- Start over for max overdue
564     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
565
566     -- Fail if the user has too many overdue items
567     LOOP
568         tmp_grp := user_object.profile;
569         LOOP
570
571             SELECT * INTO max_overdue FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 2 AND org_unit = tmp_org.id;
572
573             IF max_overdue.threshold IS NULL THEN
574                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
575             ELSE
576                 EXIT;
577             END IF;
578
579             IF tmp_grp IS NULL THEN
580                 EXIT;
581             END IF;
582         END LOOP;
583
584         IF max_overdue.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
585             EXIT;
586         END IF;
587
588         SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
589
590     END LOOP;
591
592     IF max_overdue.threshold IS NOT NULL THEN
593
594         FOR existing_sp_row IN
595                 SELECT  *
596                   FROM  actor.usr_standing_penalty
597                   WHERE usr = match_user
598                         AND org_unit = max_overdue.org_unit
599                         AND (stop_date IS NULL or stop_date > NOW())
600                         AND standing_penalty = 2
601                 LOOP
602             RETURN NEXT existing_sp_row;
603         END LOOP;
604
605         SELECT  INTO items_overdue COUNT(*)
606           FROM  action.circulation circ
607                 JOIN  actor.org_unit_full_path( max_overdue.org_unit ) fp ON (circ.circ_lib = fp.id)
608           WHERE circ.usr = match_user
609             AND circ.checkin_time IS NULL
610             AND circ.due_date < NOW()
611             AND (circ.stop_fines = 'MAXFINES' OR circ.stop_fines IS NULL);
612
613         IF items_overdue >= max_overdue.threshold::INT THEN
614             new_sp_row.usr := match_user;
615             new_sp_row.org_unit := max_overdue.org_unit;
616             new_sp_row.standing_penalty := 2;
617             RETURN NEXT new_sp_row;
618         END IF;
619     END IF;
620
621     -- Start over for max out
622     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
623
624     -- Fail if the user has too many checked out items
625     LOOP
626         tmp_grp := user_object.profile;
627         LOOP
628             SELECT * INTO max_items_out FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 3 AND org_unit = tmp_org.id;
629
630             IF max_items_out.threshold IS NULL THEN
631                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
632             ELSE
633                 EXIT;
634             END IF;
635
636             IF tmp_grp IS NULL THEN
637                 EXIT;
638             END IF;
639         END LOOP;
640
641         IF max_items_out.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
642             EXIT;
643         END IF;
644
645         SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
646
647     END LOOP;
648
649
650     -- Fail if the user has too many items checked out
651     IF max_items_out.threshold IS NOT NULL THEN
652
653         FOR existing_sp_row IN
654                 SELECT  *
655                   FROM  actor.usr_standing_penalty
656                   WHERE usr = match_user
657                         AND org_unit = max_items_out.org_unit
658                         AND (stop_date IS NULL or stop_date > NOW())
659                         AND standing_penalty = 3
660                 LOOP
661             RETURN NEXT existing_sp_row;
662         END LOOP;
663
664         SELECT  INTO items_out COUNT(*)
665           FROM  action.circulation circ
666                 JOIN  actor.org_unit_full_path( max_items_out.org_unit ) fp ON (circ.circ_lib = fp.id)
667           WHERE circ.usr = match_user
668                 AND circ.checkin_time IS NULL
669                 AND (circ.stop_fines IN ('MAXFINES','LONGOVERDUE') OR circ.stop_fines IS NULL);
670
671            IF items_out >= max_items_out.threshold::INT THEN
672             new_sp_row.usr := match_user;
673             new_sp_row.org_unit := max_items_out.org_unit;
674             new_sp_row.standing_penalty := 3;
675             RETURN NEXT new_sp_row;
676            END IF;
677     END IF;
678
679     -- Start over for collections warning
680     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
681
682     -- Fail if the user has a collections-level fine balance
683     LOOP
684         tmp_grp := user_object.profile;
685         LOOP
686             SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 4 AND org_unit = tmp_org.id;
687
688             IF max_fines.threshold IS NULL THEN
689                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
690             ELSE
691                 EXIT;
692             END IF;
693
694             IF tmp_grp IS NULL THEN
695                 EXIT;
696             END IF;
697         END LOOP;
698
699         IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
700             EXIT;
701         END IF;
702
703         SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
704
705     END LOOP;
706
707     IF max_fines.threshold IS NOT NULL THEN
708
709         FOR existing_sp_row IN
710                 SELECT  *
711                   FROM  actor.usr_standing_penalty
712                   WHERE usr = match_user
713                         AND org_unit = max_fines.org_unit
714                         AND (stop_date IS NULL or stop_date > NOW())
715                         AND standing_penalty = 4
716                 LOOP
717             RETURN NEXT existing_sp_row;
718         END LOOP;
719
720         SELECT  SUM(f.balance_owed) INTO current_fines
721           FROM  money.materialized_billable_xact_summary f
722                 JOIN (
723                     SELECT  r.id
724                       FROM  booking.reservation r
725                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (r.pickup_lib = fp.id)
726                       WHERE usr = match_user
727                             AND xact_finish IS NULL
728                                 UNION ALL
729                     SELECT  g.id
730                       FROM  money.grocery g
731                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (g.billing_location = fp.id)
732                       WHERE usr = match_user
733                             AND xact_finish IS NULL
734                                 UNION ALL
735                     SELECT  circ.id
736                       FROM  action.circulation circ
737                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (circ.circ_lib = fp.id)
738                       WHERE usr = match_user
739                             AND xact_finish IS NULL ) l USING (id);
740
741         IF current_fines >= max_fines.threshold THEN
742             new_sp_row.usr := match_user;
743             new_sp_row.org_unit := max_fines.org_unit;
744             new_sp_row.standing_penalty := 4;
745             RETURN NEXT new_sp_row;
746         END IF;
747     END IF;
748
749     -- Start over for in collections
750     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
751
752     -- Remove the in-collections penalty if the user has paid down enough
753     -- This penalty is different, because this code is not responsible for creating 
754     -- new in-collections penalties, only for removing them
755     LOOP
756         tmp_grp := user_object.profile;
757         LOOP
758             SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 30 AND org_unit = tmp_org.id;
759
760             IF max_fines.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_fines.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_fines.threshold IS NOT NULL THEN
780
781         -- first, see if the user had paid down to the threshold
782         SELECT  SUM(f.balance_owed) INTO current_fines
783           FROM  money.materialized_billable_xact_summary f
784                 JOIN (
785                     SELECT  r.id
786                       FROM  booking.reservation r
787                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (r.pickup_lib = fp.id)
788                       WHERE usr = match_user
789                             AND xact_finish IS NULL
790                                 UNION ALL
791                     SELECT  g.id
792                       FROM  money.grocery g
793                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (g.billing_location = fp.id)
794                       WHERE usr = match_user
795                             AND xact_finish IS NULL
796                                 UNION ALL
797                     SELECT  circ.id
798                       FROM  action.circulation circ
799                             JOIN  actor.org_unit_full_path( max_fines.org_unit ) fp ON (circ.circ_lib = fp.id)
800                       WHERE usr = match_user
801                             AND xact_finish IS NULL ) l USING (id);
802
803         IF current_fines IS NULL OR current_fines <= max_fines.threshold THEN
804             -- patron has paid down enough
805
806             SELECT INTO tmp_penalty * FROM config.standing_penalty WHERE id = 30;
807
808             IF tmp_penalty.org_depth IS NOT NULL THEN
809
810                 -- since this code is not responsible for applying the penalty, it can't 
811                 -- guarantee the current context org will match the org at which the penalty 
812                 --- was applied.  search up the org tree until we hit the configured penalty depth
813                 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
814                 SELECT INTO tmp_depth depth FROM actor.org_unit_type WHERE id = tmp_org.ou_type;
815
816                 WHILE tmp_depth >= tmp_penalty.org_depth LOOP
817
818                     FOR existing_sp_row IN
819                             SELECT  *
820                             FROM  actor.usr_standing_penalty
821                             WHERE usr = match_user
822                                     AND org_unit = tmp_org.id
823                                     AND (stop_date IS NULL or stop_date > NOW())
824                                     AND standing_penalty = 30 
825                             LOOP
826
827                         -- Penalty exists, return it for removal
828                         RETURN NEXT existing_sp_row;
829                     END LOOP;
830
831                     IF tmp_org.parent_ou IS NULL THEN
832                         EXIT;
833                     END IF;
834
835                     SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
836                     SELECT INTO tmp_depth depth FROM actor.org_unit_type WHERE id = tmp_org.ou_type;
837                 END LOOP;
838
839             ELSE
840
841                 -- no penalty depth is defined, look for exact matches
842
843                 FOR existing_sp_row IN
844                         SELECT  *
845                         FROM  actor.usr_standing_penalty
846                         WHERE usr = match_user
847                                 AND org_unit = max_fines.org_unit
848                                 AND (stop_date IS NULL or stop_date > NOW())
849                                 AND standing_penalty = 30 
850                         LOOP
851                     -- Penalty exists, return it for removal
852                     RETURN NEXT existing_sp_row;
853                 END LOOP;
854             END IF;
855     
856         END IF;
857
858     END IF;
859
860     RETURN;
861 END;
862 $func$ LANGUAGE plpgsql;
863
864 COMMIT;
865