3 -- NOTE: current config.item_type should get sip2_media_type and magnetic_media columns
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 (
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
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 );
24 -- But, just to get us started, use this
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 = '';
30 INSERT INTO config.circ_modifier (code, name, description, sip2_media_type )
37 WHERE circ_modifier IS NOT NULL;
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;
46 ** Here we define the tables that make up the circ matrix. Conceptually, this implements
47 ** the "sparse matrix" that everyone talks about, instead of using traditional rules logic.
48 ** Physically, we cut the matrix up into separate tables (almost 3rd normal form!) that handle
49 ** different portions of the matrix. This wil simplify creation of the UI (I hope), and help the
50 ** developers focus on specific parts of the matrix.
53 CREATE TABLE config.circ_matrix_matchpoint (
54 id SERIAL PRIMARY KEY,
55 active BOOL NOT NULL DEFAULT TRUE,
57 org_unit INT NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED, -- Set to the top OU for the matchpoint applicability range; we can use org_unit_prox to choose the "best"
58 grp INT NOT NULL REFERENCES permission.grp_tree (id) DEFERRABLE INITIALLY DEFERRED, -- Set to the top applicable group from the group tree; will need descendents and prox functions for filtering
59 circ_modifier TEXT REFERENCES config.circ_modifier (code) DEFERRABLE INITIALLY DEFERRED,
64 copy_circ_lib INT REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
65 copy_owning_lib INT REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
66 user_home_ou INT REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
70 usr_age_lower_bound INTERVAL,
71 usr_age_upper_bound INTERVAL,
74 circulate BOOL, -- Hard "can't circ" flag requiring an override
75 duration_rule INT REFERENCES config.rule_circ_duration (id) DEFERRABLE INITIALLY DEFERRED,
76 recurring_fine_rule INT REFERENCES config.rule_recurring_fine (id) DEFERRABLE INITIALLY DEFERRED,
77 max_fine_rule INT REFERENCES config.rule_max_fine (id) DEFERRABLE INITIALLY DEFERRED,
78 hard_due_date INT REFERENCES config.hard_due_date (id) DEFERRABLE INITIALLY DEFERRED,
79 renewals INT, -- Renewal count override
80 grace_period INTERVAL, -- Grace period override
81 script_test TEXT, -- javascript source
82 total_copy_hold_ratio FLOAT,
83 available_copy_hold_ratio FLOAT
86 -- Nulls don't count for a constraint match, so we have to coalesce them into something that does.
87 CREATE UNIQUE INDEX ccmm_once_per_paramset ON config.circ_matrix_matchpoint (org_unit, grp, COALESCE(circ_modifier, ''), 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;
89 -- Tests for max items out by circ_modifier
90 CREATE TABLE config.circ_matrix_circ_mod_test (
91 id SERIAL PRIMARY KEY,
92 matchpoint INT NOT NULL REFERENCES config.circ_matrix_matchpoint (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
93 items_out INT NOT NULL -- Total current active circulations must be less than this, NULL means skip (always pass)
96 CREATE TABLE config.circ_matrix_circ_mod_test_map (
97 id SERIAL PRIMARY KEY,
98 circ_mod_test INT NOT NULL REFERENCES config.circ_matrix_circ_mod_test (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
99 circ_mod TEXT NOT NULL REFERENCES config.circ_modifier (code) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
100 CONSTRAINT cm_once_per_test UNIQUE (circ_mod_test, circ_mod)
103 CREATE TYPE action.found_circ_matrix_matchpoint AS ( success BOOL, matchpoint config.circ_matrix_matchpoint, buildrows INT[] );
105 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$
107 cn_object asset.call_number%ROWTYPE;
108 rec_descriptor metabib.rec_descriptor%ROWTYPE;
109 cur_matchpoint config.circ_matrix_matchpoint%ROWTYPE;
110 matchpoint config.circ_matrix_matchpoint%ROWTYPE;
111 weights config.circ_matrix_weights%ROWTYPE;
113 my_item_age INTERVAL;
114 denominator NUMERIC(6,2);
116 result action.found_circ_matrix_matchpoint;
119 result.success = false;
122 SELECT INTO cn_object * FROM asset.call_number WHERE id = item_object.call_number;
123 SELECT INTO rec_descriptor * FROM metabib.rec_descriptor WHERE record = cn_object.record;
125 -- Pre-generate this so we only calc it once
126 IF user_object.dob IS NOT NULL THEN
127 SELECT INTO user_age age(user_object.dob);
131 IF item_object.active_date IS NOT NULL THEN
132 SELECT INTO my_item_age age(item_object.active_date);
135 -- Grab the closest set circ weight setting.
136 SELECT INTO weights cw.*
137 FROM config.weight_assoc wa
138 JOIN config.circ_matrix_weights cw ON (cw.id = wa.circ_weights)
139 JOIN actor.org_unit_ancestors_distance( context_ou ) d ON (wa.org_unit = d.id)
144 -- No weights? Bad admin! Defaults to handle that anyway.
145 IF weights.id IS NULL THEN
147 weights.org_unit := 10.0;
148 weights.circ_modifier := 5.0;
149 weights.marc_type := 4.0;
150 weights.marc_form := 3.0;
151 weights.marc_bib_level := 2.0;
152 weights.marc_vr_format := 2.0;
153 weights.copy_circ_lib := 8.0;
154 weights.copy_owning_lib := 8.0;
155 weights.user_home_ou := 8.0;
156 weights.ref_flag := 1.0;
157 weights.juvenile_flag := 6.0;
158 weights.is_renewal := 7.0;
159 weights.usr_age_lower_bound := 0.0;
160 weights.usr_age_upper_bound := 0.0;
161 weights.item_age := 0.0;
164 -- Determine the max (expected) depth (+1) of the org tree and max depth of the permisson tree
165 -- If you break your org tree with funky parenting this may be wrong
166 -- 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
167 -- We use one denominator for all tree-based checks for when permission groups and org units have the same weighting
168 WITH all_distance(distance) AS (
169 SELECT depth AS distance FROM actor.org_unit_type
171 SELECT distance AS distance FROM permission.grp_ancestors_distance((SELECT id FROM permission.grp_tree WHERE parent IS NULL))
173 SELECT INTO denominator MAX(distance) + 1 FROM all_distance;
175 -- Loop over all the potential matchpoints
176 FOR cur_matchpoint IN
178 FROM config.circ_matrix_matchpoint m
179 /*LEFT*/ JOIN permission.grp_ancestors_distance( user_object.profile ) upgad ON m.grp = upgad.id
180 /*LEFT*/ JOIN actor.org_unit_ancestors_distance( context_ou ) ctoua ON m.org_unit = ctoua.id
181 LEFT JOIN actor.org_unit_ancestors_distance( cn_object.owning_lib ) cnoua ON m.copy_owning_lib = cnoua.id
182 LEFT JOIN actor.org_unit_ancestors_distance( item_object.circ_lib ) iooua ON m.copy_circ_lib = iooua.id
183 LEFT JOIN actor.org_unit_ancestors_distance( user_object.home_ou ) uhoua ON m.user_home_ou = uhoua.id
186 -- AND (m.grp IS NULL OR upgad.id IS NOT NULL) -- Optional Permission Group?
188 -- AND (m.org_unit IS NULL OR ctoua.id IS NOT NULL) -- Optional Org Unit?
189 AND (m.copy_owning_lib IS NULL OR cnoua.id IS NOT NULL)
190 AND (m.copy_circ_lib IS NULL OR iooua.id IS NOT NULL)
191 AND (m.user_home_ou IS NULL OR uhoua.id IS NOT NULL)
193 AND (m.is_renewal IS NULL OR m.is_renewal = renewal)
194 -- Static User Checks
195 AND (m.juvenile_flag IS NULL OR m.juvenile_flag = user_object.juvenile)
196 AND (m.usr_age_lower_bound IS NULL OR (user_age IS NOT NULL AND m.usr_age_lower_bound < user_age))
197 AND (m.usr_age_upper_bound IS NULL OR (user_age IS NOT NULL AND m.usr_age_upper_bound > user_age))
198 -- Static Item Checks
199 AND (m.circ_modifier IS NULL OR m.circ_modifier = item_object.circ_modifier)
200 AND (m.marc_type IS NULL OR m.marc_type = COALESCE(item_object.circ_as_type, rec_descriptor.item_type))
201 AND (m.marc_form IS NULL OR m.marc_form = rec_descriptor.item_form)
202 AND (m.marc_bib_level IS NULL OR m.marc_bib_level = rec_descriptor.bib_level)
203 AND (m.marc_vr_format IS NULL OR m.marc_vr_format = rec_descriptor.vr_format)
204 AND (m.ref_flag IS NULL OR m.ref_flag = item_object.ref)
205 AND (m.item_age IS NULL OR (my_item_age IS NOT NULL AND m.item_age > my_item_age))
208 CASE WHEN upgad.distance IS NOT NULL THEN 2^(2*weights.grp - (upgad.distance/denominator)) ELSE 0.0 END +
210 CASE WHEN ctoua.distance IS NOT NULL THEN 2^(2*weights.org_unit - (ctoua.distance/denominator)) ELSE 0.0 END +
211 CASE WHEN cnoua.distance IS NOT NULL THEN 2^(2*weights.copy_owning_lib - (cnoua.distance/denominator)) ELSE 0.0 END +
212 CASE WHEN iooua.distance IS NOT NULL THEN 2^(2*weights.copy_circ_lib - (iooua.distance/denominator)) ELSE 0.0 END +
213 CASE WHEN uhoua.distance IS NOT NULL THEN 2^(2*weights.user_home_ou - (uhoua.distance/denominator)) ELSE 0.0 END +
214 -- Circ Type -- Note: 4^x is equiv to 2^(2*x)
215 CASE WHEN m.is_renewal IS NOT NULL THEN 4^weights.is_renewal ELSE 0.0 END +
216 -- Static User Checks
217 CASE WHEN m.juvenile_flag IS NOT NULL THEN 4^weights.juvenile_flag ELSE 0.0 END +
218 CASE WHEN m.usr_age_lower_bound IS NOT NULL THEN 4^weights.usr_age_lower_bound ELSE 0.0 END +
219 CASE WHEN m.usr_age_upper_bound IS NOT NULL THEN 4^weights.usr_age_upper_bound ELSE 0.0 END +
220 -- Static Item Checks
221 CASE WHEN m.circ_modifier IS NOT NULL THEN 4^weights.circ_modifier ELSE 0.0 END +
222 CASE WHEN m.marc_type IS NOT NULL THEN 4^weights.marc_type ELSE 0.0 END +
223 CASE WHEN m.marc_form IS NOT NULL THEN 4^weights.marc_form ELSE 0.0 END +
224 CASE WHEN m.marc_vr_format IS NOT NULL THEN 4^weights.marc_vr_format ELSE 0.0 END +
225 CASE WHEN m.ref_flag IS NOT NULL THEN 4^weights.ref_flag ELSE 0.0 END +
226 -- Item age has a slight adjustment to weight based on value.
227 -- This should ensure that a shorter age limit comes first when all else is equal.
228 -- NOTE: This assumes that intervals will normally be in days.
229 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,
230 -- Final sort on id, so that if two rules have the same sorting in the previous sort they have a defined order
231 -- This prevents "we changed the table order by updating a rule, and we started getting different results"
234 -- Record the full matching row list
235 row_list := row_list || cur_matchpoint.id;
237 -- No matchpoint yet?
238 IF matchpoint.id IS NULL THEN
239 -- Take the entire matchpoint as a starting point
240 matchpoint := cur_matchpoint;
241 CONTINUE; -- No need to look at this row any more.
244 -- Incomplete matchpoint?
245 IF matchpoint.circulate IS NULL THEN
246 matchpoint.circulate := cur_matchpoint.circulate;
248 IF matchpoint.duration_rule IS NULL THEN
249 matchpoint.duration_rule := cur_matchpoint.duration_rule;
251 IF matchpoint.recurring_fine_rule IS NULL THEN
252 matchpoint.recurring_fine_rule := cur_matchpoint.recurring_fine_rule;
254 IF matchpoint.max_fine_rule IS NULL THEN
255 matchpoint.max_fine_rule := cur_matchpoint.max_fine_rule;
257 IF matchpoint.hard_due_date IS NULL THEN
258 matchpoint.hard_due_date := cur_matchpoint.hard_due_date;
260 IF matchpoint.total_copy_hold_ratio IS NULL THEN
261 matchpoint.total_copy_hold_ratio := cur_matchpoint.total_copy_hold_ratio;
263 IF matchpoint.available_copy_hold_ratio IS NULL THEN
264 matchpoint.available_copy_hold_ratio := cur_matchpoint.available_copy_hold_ratio;
266 IF matchpoint.renewals IS NULL THEN
267 matchpoint.renewals := cur_matchpoint.renewals;
269 IF matchpoint.grace_period IS NULL THEN
270 matchpoint.grace_period := cur_matchpoint.grace_period;
274 -- Check required fields
275 IF matchpoint.circulate IS NOT NULL AND
276 matchpoint.duration_rule IS NOT NULL AND
277 matchpoint.recurring_fine_rule IS NOT NULL AND
278 matchpoint.max_fine_rule IS NOT NULL THEN
279 -- All there? We have a completed match.
280 result.success := true;
283 -- Include the assembled matchpoint, even if it isn't complete
284 result.matchpoint := matchpoint;
286 -- Include (for debugging) the full list of matching rows
287 result.buildrows := row_list;
289 -- Hand the result back to caller
292 $func$ LANGUAGE plpgsql;
294 -- Helper function - For manual calling, it can be easier to pass in IDs instead of objects
295 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$
297 item_object asset.copy%ROWTYPE;
298 user_object actor.usr%ROWTYPE;
300 SELECT INTO item_object * FROM asset.copy WHERE id = match_item;
301 SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
303 RETURN QUERY SELECT * FROM action.find_circ_matrix_matchpoint( context_ou, item_object, user_object, renewal );
305 $func$ LANGUAGE plpgsql;
307 CREATE TYPE action.hold_stats AS (
311 total_copy_ratio FLOAT,
312 available_copy_ratio FLOAT
315 CREATE OR REPLACE FUNCTION action.copy_related_hold_stats (copy_id INT) RETURNS action.hold_stats AS $func$
317 output action.hold_stats%ROWTYPE;
320 available_count INT := 0;
321 hold_map_data RECORD;
324 output.hold_count := 0;
325 output.copy_count := 0;
326 output.available_count := 0;
328 SELECT COUNT( DISTINCT m.hold ) INTO hold_count
329 FROM action.hold_copy_map m
330 JOIN action.hold_request h ON (m.hold = h.id)
331 WHERE m.target_copy = copy_id
334 output.hold_count := hold_count;
336 IF output.hold_count > 0 THEN
338 SELECT DISTINCT m.target_copy,
340 FROM action.hold_copy_map m
341 JOIN asset.copy acp ON (m.target_copy = acp.id)
342 JOIN action.hold_request h ON (m.hold = h.id)
343 WHERE m.hold IN ( SELECT DISTINCT hold FROM action.hold_copy_map WHERE target_copy = copy_id ) AND NOT h.frozen
345 output.copy_count := output.copy_count + 1;
346 IF hold_map_data.status IN (0,7,12) THEN
347 output.available_count := output.available_count + 1;
350 output.total_copy_ratio = output.copy_count::FLOAT / output.hold_count::FLOAT;
351 output.available_copy_ratio = output.available_count::FLOAT / output.hold_count::FLOAT;
358 $func$ LANGUAGE PLPGSQL;
360 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 );
361 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$
363 user_object actor.usr%ROWTYPE;
364 standing_penalty config.standing_penalty%ROWTYPE;
365 item_object asset.copy%ROWTYPE;
366 item_status_object config.copy_status%ROWTYPE;
367 item_location_object asset.copy_location%ROWTYPE;
368 result action.circ_matrix_test_result;
369 circ_test action.found_circ_matrix_matchpoint;
370 circ_matchpoint config.circ_matrix_matchpoint%ROWTYPE;
371 out_by_circ_mod config.circ_matrix_circ_mod_test%ROWTYPE;
372 circ_mod_map config.circ_matrix_circ_mod_test_map%ROWTYPE;
373 hold_ratio action.hold_stats%ROWTYPE;
376 context_org_list INT[];
379 -- Assume success unless we hit a failure condition
380 result.success := TRUE;
382 -- Fail if the user is BARRED
383 SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
385 -- Fail if we couldn't find the user
386 IF user_object.id IS NULL THEN
387 result.fail_part := 'no_user';
388 result.success := FALSE;
394 SELECT INTO item_object * FROM asset.copy WHERE id = match_item;
396 -- Fail if we couldn't find the item
397 IF item_object.id IS NULL THEN
398 result.fail_part := 'no_item';
399 result.success := FALSE;
405 IF user_object.barred IS TRUE THEN
406 result.fail_part := 'actor.usr.barred';
407 result.success := FALSE;
412 -- Fail if the item can't circulate
413 IF item_object.circulate IS FALSE THEN
414 result.fail_part := 'asset.copy.circulate';
415 result.success := FALSE;
420 -- Fail if the item isn't in a circulateable status on a non-renewal
421 IF NOT renewal AND item_object.status NOT IN ( 0, 7, 8 ) THEN
422 result.fail_part := 'asset.copy.status';
423 result.success := FALSE;
426 ELSIF renewal AND item_object.status <> 1 THEN
427 result.fail_part := 'asset.copy.status';
428 result.success := FALSE;
433 -- Fail if the item can't circulate because of the shelving location
434 SELECT INTO item_location_object * FROM asset.copy_location WHERE id = item_object.location;
435 IF item_location_object.circulate IS FALSE THEN
436 result.fail_part := 'asset.copy_location.circulate';
437 result.success := FALSE;
442 SELECT INTO circ_test * FROM action.find_circ_matrix_matchpoint(circ_ou, item_object, user_object, renewal);
444 circ_matchpoint := circ_test.matchpoint;
445 result.matchpoint := circ_matchpoint.id;
446 result.circulate := circ_matchpoint.circulate;
447 result.duration_rule := circ_matchpoint.duration_rule;
448 result.recurring_fine_rule := circ_matchpoint.recurring_fine_rule;
449 result.max_fine_rule := circ_matchpoint.max_fine_rule;
450 result.hard_due_date := circ_matchpoint.hard_due_date;
451 result.renewals := circ_matchpoint.renewals;
452 result.grace_period := circ_matchpoint.grace_period;
453 result.buildrows := circ_test.buildrows;
455 -- Fail if we couldn't find a matchpoint
456 IF circ_test.success = false THEN
457 result.fail_part := 'no_matchpoint';
458 result.success := FALSE;
461 RETURN; -- All tests after this point require a matchpoint. No sense in running on an incomplete or missing one.
464 -- Apparently....use the circ matchpoint org unit to determine what org units are valid.
465 SELECT INTO context_org_list ARRAY_ACCUM(id) FROM actor.org_unit_full_path( circ_matchpoint.org_unit );
468 penalty_type = '%RENEW%';
470 penalty_type = '%CIRC%';
473 FOR standing_penalty IN
474 SELECT DISTINCT csp.*
475 FROM actor.usr_standing_penalty usp
476 JOIN config.standing_penalty csp ON (csp.id = usp.standing_penalty)
477 WHERE usr = match_user
478 AND usp.org_unit IN ( SELECT * FROM unnest(context_org_list) )
479 AND (usp.stop_date IS NULL or usp.stop_date > NOW())
480 AND csp.block_list LIKE penalty_type LOOP
482 result.fail_part := standing_penalty.name;
483 result.success := FALSE;
488 -- Fail if the test is set to hard non-circulating
489 IF circ_matchpoint.circulate IS FALSE THEN
490 result.fail_part := 'config.circ_matrix_test.circulate';
491 result.success := FALSE;
496 -- Fail if the total copy-hold ratio is too low
497 IF circ_matchpoint.total_copy_hold_ratio IS NOT NULL THEN
498 SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
499 IF hold_ratio.total_copy_ratio IS NOT NULL AND hold_ratio.total_copy_ratio < circ_matchpoint.total_copy_hold_ratio THEN
500 result.fail_part := 'config.circ_matrix_test.total_copy_hold_ratio';
501 result.success := FALSE;
507 -- Fail if the available copy-hold ratio is too low
508 IF circ_matchpoint.available_copy_hold_ratio IS NOT NULL THEN
509 IF hold_ratio.hold_count IS NULL THEN
510 SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
512 IF hold_ratio.available_copy_ratio IS NOT NULL AND hold_ratio.available_copy_ratio < circ_matchpoint.available_copy_hold_ratio THEN
513 result.fail_part := 'config.circ_matrix_test.available_copy_hold_ratio';
514 result.success := FALSE;
520 -- Fail if the user has too many items with specific circ_modifiers checked out
521 FOR out_by_circ_mod IN SELECT * FROM config.circ_matrix_circ_mod_test WHERE matchpoint = circ_matchpoint.id LOOP
522 SELECT INTO items_out COUNT(*)
523 FROM action.circulation circ
524 JOIN asset.copy cp ON (cp.id = circ.target_copy)
525 WHERE circ.usr = match_user
526 AND circ.circ_lib IN ( SELECT * FROM unnest(context_org_list) )
527 AND circ.checkin_time IS NULL
528 AND (circ.stop_fines IN ('MAXFINES','LONGOVERDUE') OR circ.stop_fines IS NULL)
529 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);
530 IF items_out >= out_by_circ_mod.items_out THEN
531 result.fail_part := 'config.circ_matrix_circ_mod_test';
532 result.success := FALSE;
538 -- If we passed everything, return the successful matchpoint id
545 $func$ LANGUAGE plpgsql;
547 CREATE OR REPLACE FUNCTION action.item_user_circ_test( INT, BIGINT, INT ) RETURNS SETOF action.circ_matrix_test_result AS $func$
548 SELECT * FROM action.item_user_circ_test( $1, $2, $3, FALSE );
551 CREATE OR REPLACE FUNCTION action.item_user_renew_test( INT, BIGINT, INT ) RETURNS SETOF action.circ_matrix_test_result AS $func$
552 SELECT * FROM action.item_user_circ_test( $1, $2, $3, TRUE );
556 CREATE OR REPLACE FUNCTION actor.calculate_system_penalties( match_user INT, context_org INT ) RETURNS SETOF actor.usr_standing_penalty AS $func$
558 user_object actor.usr%ROWTYPE;
559 new_sp_row actor.usr_standing_penalty%ROWTYPE;
560 existing_sp_row actor.usr_standing_penalty%ROWTYPE;
561 collections_fines permission.grp_penalty_threshold%ROWTYPE;
562 max_fines permission.grp_penalty_threshold%ROWTYPE;
563 max_overdue permission.grp_penalty_threshold%ROWTYPE;
564 max_items_out permission.grp_penalty_threshold%ROWTYPE;
568 context_org_list INT[];
569 current_fines NUMERIC(8,2) := 0.0;
570 tmp_fines NUMERIC(8,2);
573 tmp_org actor.org_unit%ROWTYPE;
574 tmp_penalty config.standing_penalty%ROWTYPE;
577 SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
580 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
582 -- Fail if the user has a high fine balance
584 tmp_grp := user_object.profile;
586 SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 1 AND org_unit = tmp_org.id;
588 IF max_fines.threshold IS NULL THEN
589 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
594 IF tmp_grp IS NULL THEN
599 IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
603 SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
607 IF max_fines.threshold IS NOT NULL THEN
611 FROM actor.usr_standing_penalty
612 WHERE usr = match_user
613 AND org_unit = max_fines.org_unit
614 AND (stop_date IS NULL or stop_date > NOW())
615 AND standing_penalty = 1;
617 SELECT INTO context_org_list ARRAY_ACCUM(id) FROM actor.org_unit_full_path( max_fines.org_unit );
619 SELECT SUM(f.balance_owed) INTO current_fines
620 FROM money.materialized_billable_xact_summary f
623 FROM booking.reservation r
624 WHERE r.usr = match_user
625 AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
626 AND xact_finish IS NULL
630 WHERE g.usr = match_user
631 AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
632 AND xact_finish IS NULL
635 FROM action.circulation circ
636 WHERE circ.usr = match_user
637 AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
638 AND xact_finish IS NULL ) l USING (id);
640 IF current_fines >= max_fines.threshold THEN
641 new_sp_row.usr := match_user;
642 new_sp_row.org_unit := max_fines.org_unit;
643 new_sp_row.standing_penalty := 1;
644 RETURN NEXT new_sp_row;
648 -- Start over for max overdue
649 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
651 -- Fail if the user has too many overdue items
653 tmp_grp := user_object.profile;
656 SELECT * INTO max_overdue FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 2 AND org_unit = tmp_org.id;
658 IF max_overdue.threshold IS NULL THEN
659 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
664 IF tmp_grp IS NULL THEN
669 IF max_overdue.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
673 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
677 IF max_overdue.threshold IS NOT NULL THEN
681 FROM actor.usr_standing_penalty
682 WHERE usr = match_user
683 AND org_unit = max_overdue.org_unit
684 AND (stop_date IS NULL or stop_date > NOW())
685 AND standing_penalty = 2;
687 SELECT INTO items_overdue COUNT(*)
688 FROM action.circulation circ
689 JOIN actor.org_unit_full_path( max_overdue.org_unit ) fp ON (circ.circ_lib = fp.id)
690 WHERE circ.usr = match_user
691 AND circ.checkin_time IS NULL
692 AND circ.due_date < NOW()
693 AND (circ.stop_fines = 'MAXFINES' OR circ.stop_fines IS NULL);
695 IF items_overdue >= max_overdue.threshold::INT THEN
696 new_sp_row.usr := match_user;
697 new_sp_row.org_unit := max_overdue.org_unit;
698 new_sp_row.standing_penalty := 2;
699 RETURN NEXT new_sp_row;
703 -- Start over for max out
704 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
706 -- Fail if the user has too many checked out items
708 tmp_grp := user_object.profile;
710 SELECT * INTO max_items_out FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 3 AND org_unit = tmp_org.id;
712 IF max_items_out.threshold IS NULL THEN
713 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
718 IF tmp_grp IS NULL THEN
723 IF max_items_out.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
727 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
732 -- Fail if the user has too many items checked out
733 IF max_items_out.threshold IS NOT NULL THEN
737 FROM actor.usr_standing_penalty
738 WHERE usr = match_user
739 AND org_unit = max_items_out.org_unit
740 AND (stop_date IS NULL or stop_date > NOW())
741 AND standing_penalty = 3;
743 SELECT INTO items_out COUNT(*)
744 FROM action.circulation circ
745 JOIN actor.org_unit_full_path( max_items_out.org_unit ) fp ON (circ.circ_lib = fp.id)
746 WHERE circ.usr = match_user
747 AND circ.checkin_time IS NULL
748 AND (circ.stop_fines IN ('MAXFINES','LONGOVERDUE') OR circ.stop_fines IS NULL);
750 IF items_out >= max_items_out.threshold::INT THEN
751 new_sp_row.usr := match_user;
752 new_sp_row.org_unit := max_items_out.org_unit;
753 new_sp_row.standing_penalty := 3;
754 RETURN NEXT new_sp_row;
758 -- Start over for collections warning
759 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
761 -- Fail if the user has a collections-level fine balance
763 tmp_grp := user_object.profile;
765 SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 4 AND org_unit = tmp_org.id;
767 IF max_fines.threshold IS NULL THEN
768 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
773 IF tmp_grp IS NULL THEN
778 IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
782 SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
786 IF max_fines.threshold IS NOT NULL THEN
790 FROM actor.usr_standing_penalty
791 WHERE usr = match_user
792 AND org_unit = max_fines.org_unit
793 AND (stop_date IS NULL or stop_date > NOW())
794 AND standing_penalty = 4;
796 SELECT INTO context_org_list ARRAY_ACCUM(id) FROM actor.org_unit_full_path( max_fines.org_unit );
798 SELECT SUM(f.balance_owed) INTO current_fines
799 FROM money.materialized_billable_xact_summary f
802 FROM booking.reservation r
803 WHERE r.usr = match_user
804 AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
805 AND r.xact_finish IS NULL
809 WHERE g.usr = match_user
810 AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
811 AND g.xact_finish IS NULL
814 FROM action.circulation circ
815 WHERE circ.usr = match_user
816 AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
817 AND circ.xact_finish IS NULL ) l USING (id);
819 IF current_fines >= max_fines.threshold THEN
820 new_sp_row.usr := match_user;
821 new_sp_row.org_unit := max_fines.org_unit;
822 new_sp_row.standing_penalty := 4;
823 RETURN NEXT new_sp_row;
827 -- Start over for in collections
828 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
830 -- Remove the in-collections penalty if the user has paid down enough
831 -- This penalty is different, because this code is not responsible for creating
832 -- new in-collections penalties, only for removing them
834 tmp_grp := user_object.profile;
836 SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 30 AND org_unit = tmp_org.id;
838 IF max_fines.threshold IS NULL THEN
839 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
844 IF tmp_grp IS NULL THEN
849 IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
853 SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
857 IF max_fines.threshold IS NOT NULL THEN
859 SELECT INTO context_org_list ARRAY_ACCUM(id) FROM actor.org_unit_full_path( max_fines.org_unit );
861 -- first, see if the user had paid down to the threshold
862 SELECT SUM(f.balance_owed) INTO current_fines
863 FROM money.materialized_billable_xact_summary f
866 FROM booking.reservation r
867 WHERE r.usr = match_user
868 AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
869 AND r.xact_finish IS NULL
873 WHERE g.usr = match_user
874 AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
875 AND g.xact_finish IS NULL
878 FROM action.circulation circ
879 WHERE circ.usr = match_user
880 AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
881 AND circ.xact_finish IS NULL ) l USING (id);
883 IF current_fines IS NULL OR current_fines <= max_fines.threshold THEN
884 -- patron has paid down enough
886 SELECT INTO tmp_penalty * FROM config.standing_penalty WHERE id = 30;
888 IF tmp_penalty.org_depth IS NOT NULL THEN
890 -- since this code is not responsible for applying the penalty, it can't
891 -- guarantee the current context org will match the org at which the penalty
892 --- was applied. search up the org tree until we hit the configured penalty depth
893 SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
894 SELECT INTO tmp_depth depth FROM actor.org_unit_type WHERE id = tmp_org.ou_type;
896 WHILE tmp_depth >= tmp_penalty.org_depth LOOP
900 FROM actor.usr_standing_penalty
901 WHERE usr = match_user
902 AND org_unit = tmp_org.id
903 AND (stop_date IS NULL or stop_date > NOW())
904 AND standing_penalty = 30;
906 IF tmp_org.parent_ou IS NULL THEN
910 SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
911 SELECT INTO tmp_depth depth FROM actor.org_unit_type WHERE id = tmp_org.ou_type;
916 -- no penalty depth is defined, look for exact matches
920 FROM actor.usr_standing_penalty
921 WHERE usr = match_user
922 AND org_unit = max_fines.org_unit
923 AND (stop_date IS NULL or stop_date > NOW())
924 AND standing_penalty = 30;
933 $func$ LANGUAGE plpgsql;