]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/sql/Pg/090.schema.action.sql
LP1182519 Per-Hold Behind Desk Value
[working/Evergreen.git] / Open-ILS / src / sql / Pg / 090.schema.action.sql
1 /*
2  * Copyright (C) 2004-2008  Georgia Public Library Service
3  * Copyright (C) 2007-2008  Equinox Software, Inc.
4  * Mike Rylander <miker@esilibrary.com> 
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU General Public License
8  * as published by the Free Software Foundation; either version 2
9  * of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  *
16  */
17
18 DROP SCHEMA IF EXISTS action CASCADE;
19
20 BEGIN;
21
22 CREATE SCHEMA action;
23
24 CREATE TABLE action.in_house_use (
25         id              SERIAL                          PRIMARY KEY,
26         item            BIGINT                          NOT NULL, -- REFERENCES asset.copy (id) DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
27         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
28         org_unit        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
29         use_time        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
30 );
31 CREATE INDEX action_in_house_use_staff_idx      ON action.in_house_use ( staff );
32
33 CREATE TABLE action.non_cataloged_circulation (
34         id              SERIAL                          PRIMARY KEY,
35         patron          INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
36         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
37         circ_lib        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
38         item_type       INT                             NOT NULL REFERENCES config.non_cataloged_type (id) DEFERRABLE INITIALLY DEFERRED,
39         circ_time       TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
40 );
41 CREATE INDEX action_non_cat_circ_patron_idx ON action.non_cataloged_circulation ( patron );
42 CREATE INDEX action_non_cat_circ_staff_idx  ON action.non_cataloged_circulation ( staff );
43
44 CREATE TABLE action.non_cat_in_house_use (
45         id              SERIAL                          PRIMARY KEY,
46         item_type       BIGINT                          NOT NULL REFERENCES config.non_cataloged_type(id) DEFERRABLE INITIALLY DEFERRED,
47         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
48         org_unit        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
49         use_time        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
50 );
51 CREATE INDEX non_cat_in_house_use_staff_idx ON action.non_cat_in_house_use ( staff );
52
53 CREATE TABLE action.survey (
54         id              SERIAL                          PRIMARY KEY,
55         owner           INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
56         start_date      TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
57         end_date        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW() + '10 years'::INTERVAL,
58         usr_summary     BOOL                            NOT NULL DEFAULT FALSE,
59         opac            BOOL                            NOT NULL DEFAULT FALSE,
60         poll            BOOL                            NOT NULL DEFAULT FALSE,
61         required        BOOL                            NOT NULL DEFAULT FALSE,
62         name            TEXT                            NOT NULL,
63         description     TEXT                            NOT NULL
64 );
65 CREATE UNIQUE INDEX asv_once_per_owner_idx ON action.survey (owner,name);
66
67 CREATE TABLE action.survey_question (
68         id              SERIAL  PRIMARY KEY,
69         survey          INT     NOT NULL REFERENCES action.survey DEFERRABLE INITIALLY DEFERRED,
70         question        TEXT    NOT NULL
71 );
72
73 CREATE TABLE action.survey_answer (
74         id              SERIAL  PRIMARY KEY,
75         question        INT     NOT NULL REFERENCES action.survey_question DEFERRABLE INITIALLY DEFERRED,
76         answer          TEXT    NOT NULL
77 );
78
79 CREATE SEQUENCE action.survey_response_group_id_seq;
80
81 CREATE TABLE action.survey_response (
82         id                      BIGSERIAL                       PRIMARY KEY,
83         response_group_id       INT,
84         usr                     INT, -- REFERENCES actor.usr
85         survey                  INT                             NOT NULL REFERENCES action.survey DEFERRABLE INITIALLY DEFERRED,
86         question                INT                             NOT NULL REFERENCES action.survey_question DEFERRABLE INITIALLY DEFERRED,
87         answer                  INT                             NOT NULL REFERENCES action.survey_answer DEFERRABLE INITIALLY DEFERRED,
88         answer_date             TIMESTAMP WITH TIME ZONE,
89         effective_date          TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
90 );
91 CREATE INDEX action_survey_response_usr_idx ON action.survey_response ( usr );
92
93 CREATE OR REPLACE FUNCTION action.survey_response_answer_date_fixup () RETURNS TRIGGER AS '
94 BEGIN
95         NEW.answer_date := NOW();
96         RETURN NEW;
97 END;
98 ' LANGUAGE 'plpgsql';
99 CREATE TRIGGER action_survey_response_answer_date_fixup_tgr
100         BEFORE INSERT ON action.survey_response
101         FOR EACH ROW
102         EXECUTE PROCEDURE action.survey_response_answer_date_fixup ();
103
104 CREATE TABLE action.archive_actor_stat_cat (
105     id          BIGSERIAL   PRIMARY KEY,
106     xact        BIGINT      NOT NULL, -- action.circulation (+aged/all)
107     stat_cat    INT         NOT NULL,
108     value       TEXT        NOT NULL
109 );
110
111 CREATE TABLE action.archive_asset_stat_cat (
112     id          BIGSERIAL   PRIMARY KEY,
113     xact        BIGINT      NOT NULL, -- action.circulation (+aged/all)
114     stat_cat    INT         NOT NULL,
115     value       TEXT        NOT NULL
116 );
117
118
119 CREATE TABLE action.circulation (
120         target_copy             BIGINT                          NOT NULL, -- asset.copy.id
121         circ_lib                INT                             NOT NULL, -- actor.org_unit.id
122         circ_staff              INT                             NOT NULL, -- actor.usr.id
123         checkin_staff           INT,                                      -- actor.usr.id
124         checkin_lib             INT,                                      -- actor.org_unit.id
125         renewal_remaining       INT                             NOT NULL, -- derived from "circ duration" rule
126     grace_period           INTERVAL             NOT NULL, -- derived from "circ fine" rule
127         due_date                TIMESTAMP WITH TIME ZONE,
128         stop_fines_time         TIMESTAMP WITH TIME ZONE,
129         checkin_time            TIMESTAMP WITH TIME ZONE,
130         create_time             TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
131         duration                INTERVAL,                                 -- derived from "circ duration" rule
132         fine_interval           INTERVAL                        NOT NULL DEFAULT '1 day'::INTERVAL, -- derived from "circ fine" rule
133         recurring_fine          NUMERIC(6,2),                             -- derived from "circ fine" rule
134         max_fine                NUMERIC(6,2),                             -- derived from "max fine" rule
135         phone_renewal           BOOL                            NOT NULL DEFAULT FALSE,
136         desk_renewal            BOOL                            NOT NULL DEFAULT FALSE,
137         opac_renewal            BOOL                            NOT NULL DEFAULT FALSE,
138         duration_rule           TEXT                            NOT NULL, -- name of "circ duration" rule
139         recurring_fine_rule     TEXT                            NOT NULL, -- name of "circ fine" rule
140         max_fine_rule           TEXT                            NOT NULL, -- name of "max fine" rule
141         stop_fines              TEXT                            CHECK (stop_fines IN (
142                                                'CHECKIN','CLAIMSRETURNED','LOST','MAXFINES','RENEW','LONGOVERDUE','CLAIMSNEVERCHECKEDOUT')),
143         workstation         INT        REFERENCES actor.workstation(id)
144                                        ON DELETE SET NULL
145                                                                    DEFERRABLE INITIALLY DEFERRED,
146         checkin_workstation INT        REFERENCES actor.workstation(id)
147                                        ON DELETE SET NULL
148                                                                    DEFERRABLE INITIALLY DEFERRED,
149         copy_location   INT                             NOT NULL DEFAULT 1 REFERENCES asset.copy_location (id) DEFERRABLE INITIALLY DEFERRED,
150         checkin_scan_time   TIMESTAMP WITH TIME ZONE
151 ) INHERITS (money.billable_xact);
152 ALTER TABLE action.circulation ADD PRIMARY KEY (id);
153 ALTER TABLE action.circulation
154         ADD COLUMN parent_circ BIGINT
155         REFERENCES action.circulation( id )
156         DEFERRABLE INITIALLY DEFERRED;
157 CREATE INDEX circ_open_xacts_idx ON action.circulation (usr) WHERE xact_finish IS NULL;
158 CREATE INDEX circ_outstanding_idx ON action.circulation (usr) WHERE checkin_time IS NULL;
159 CREATE INDEX circ_checkin_time ON "action".circulation (checkin_time) WHERE checkin_time IS NOT NULL;
160 CREATE INDEX circ_circ_lib_idx ON "action".circulation (circ_lib);
161 CREATE INDEX circ_open_date_idx ON "action".circulation (xact_start) WHERE xact_finish IS NULL;
162 CREATE INDEX circ_all_usr_idx       ON action.circulation ( usr );
163 CREATE INDEX circ_circ_staff_idx    ON action.circulation ( circ_staff );
164 CREATE INDEX circ_checkin_staff_idx ON action.circulation ( checkin_staff );
165 CREATE INDEX action_circulation_target_copy_idx ON action.circulation (target_copy);
166 CREATE UNIQUE INDEX circ_parent_idx ON action.circulation ( parent_circ ) WHERE parent_circ IS NOT NULL;
167 CREATE UNIQUE INDEX only_one_concurrent_checkout_per_copy ON action.circulation(target_copy) WHERE checkin_time IS NULL;
168
169 CREATE TRIGGER action_circulation_target_copy_trig AFTER INSERT OR UPDATE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE evergreen.fake_fkey_tgr('target_copy');
170
171 CREATE TRIGGER mat_summary_create_tgr AFTER INSERT ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_create ('circulation');
172 CREATE TRIGGER mat_summary_change_tgr AFTER UPDATE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_update ();
173 CREATE TRIGGER mat_summary_remove_tgr AFTER DELETE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_delete ();
174
175 CREATE OR REPLACE FUNCTION action.push_circ_due_time () RETURNS TRIGGER AS $$
176 BEGIN
177     IF (EXTRACT(EPOCH FROM NEW.duration)::INT % EXTRACT(EPOCH FROM '1 day'::INTERVAL)::INT) = 0 THEN
178         NEW.due_date = (NEW.due_date::DATE + '1 day'::INTERVAL - '1 second'::INTERVAL)::TIMESTAMPTZ;
179     END IF;
180
181     RETURN NEW;
182 END;
183 $$ LANGUAGE PLPGSQL;
184
185 CREATE TRIGGER push_due_date_tgr BEFORE INSERT OR UPDATE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE action.push_circ_due_time();
186
187 CREATE OR REPLACE FUNCTION action.fill_circ_copy_location () RETURNS TRIGGER AS $$
188 BEGIN
189     SELECT INTO NEW.copy_location location FROM asset.copy WHERE id = NEW.target_copy;
190     RETURN NEW;
191 END;
192 $$ LANGUAGE PLPGSQL;
193
194 CREATE TRIGGER fill_circ_copy_location_tgr BEFORE INSERT ON action.circulation FOR EACH ROW EXECUTE PROCEDURE action.fill_circ_copy_location();
195
196 CREATE OR REPLACE FUNCTION action.archive_stat_cats () RETURNS TRIGGER AS $$
197 BEGIN
198     INSERT INTO action.archive_actor_stat_cat(xact, stat_cat, value)
199         SELECT NEW.id, asceum.stat_cat, asceum.stat_cat_entry
200         FROM actor.stat_cat_entry_usr_map asceum
201              JOIN actor.stat_cat sc ON asceum.stat_cat = sc.id
202         WHERE NEW.usr = asceum.target_usr AND sc.checkout_archive;
203     INSERT INTO action.archive_asset_stat_cat(xact, stat_cat, value)
204         SELECT NEW.id, ascecm.stat_cat, asce.value
205         FROM asset.stat_cat_entry_copy_map ascecm
206              JOIN asset.stat_cat sc ON ascecm.stat_cat = sc.id
207              JOIN asset.stat_cat_entry asce ON ascecm.stat_cat_entry = asce.id
208         WHERE NEW.target_copy = ascecm.owning_copy AND sc.checkout_archive;
209     RETURN NULL;
210 END;
211 $$ LANGUAGE PLPGSQL;
212
213 CREATE TRIGGER archive_stat_cats_tgr AFTER INSERT ON action.circulation FOR EACH ROW EXECUTE PROCEDURE action.archive_stat_cats();
214
215 CREATE TABLE action.aged_circulation (
216         usr_post_code           TEXT,
217         usr_home_ou             INT     NOT NULL,
218         usr_profile             INT     NOT NULL,
219         usr_birth_year          INT,
220         copy_call_number        INT     NOT NULL,
221         copy_owning_lib         INT     NOT NULL,
222         copy_circ_lib           INT     NOT NULL,
223         copy_bib_record         BIGINT  NOT NULL,
224         LIKE action.circulation
225
226 );
227 ALTER TABLE action.aged_circulation ADD PRIMARY KEY (id);
228 ALTER TABLE action.aged_circulation DROP COLUMN usr;
229 CREATE INDEX aged_circ_circ_lib_idx ON "action".aged_circulation (circ_lib);
230 CREATE INDEX aged_circ_start_idx ON "action".aged_circulation (xact_start);
231 CREATE INDEX aged_circ_copy_circ_lib_idx ON "action".aged_circulation (copy_circ_lib);
232 CREATE INDEX aged_circ_copy_owning_lib_idx ON "action".aged_circulation (copy_owning_lib);
233 CREATE INDEX aged_circ_copy_location_idx ON "action".aged_circulation (copy_location);
234 CREATE INDEX action_aged_circulation_target_copy_idx ON action.aged_circulation (target_copy);
235
236 CREATE OR REPLACE VIEW action.all_circulation AS
237     SELECT  id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
238         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
239         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
240         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
241         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
242         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ
243       FROM  action.aged_circulation
244             UNION ALL
245     SELECT  DISTINCT circ.id,COALESCE(a.post_code,b.post_code) AS usr_post_code, p.home_ou AS usr_home_ou, p.profile AS usr_profile, EXTRACT(YEAR FROM p.dob)::INT AS usr_birth_year,
246         cp.call_number AS copy_call_number, circ.copy_location, cn.owning_lib AS copy_owning_lib, cp.circ_lib AS copy_circ_lib,
247         cn.record AS copy_bib_record, circ.xact_start, circ.xact_finish, circ.target_copy, circ.circ_lib, circ.circ_staff, circ.checkin_staff,
248         circ.checkin_lib, circ.renewal_remaining, circ.grace_period, circ.due_date, circ.stop_fines_time, circ.checkin_time, circ.create_time, circ.duration,
249         circ.fine_interval, circ.recurring_fine, circ.max_fine, circ.phone_renewal, circ.desk_renewal, circ.opac_renewal, circ.duration_rule,
250         circ.recurring_fine_rule, circ.max_fine_rule, circ.stop_fines, circ.workstation, circ.checkin_workstation, circ.checkin_scan_time,
251         circ.parent_circ
252       FROM  action.circulation circ
253         JOIN asset.copy cp ON (circ.target_copy = cp.id)
254         JOIN asset.call_number cn ON (cp.call_number = cn.id)
255         JOIN actor.usr p ON (circ.usr = p.id)
256         LEFT JOIN actor.usr_address a ON (p.mailing_address = a.id)
257         LEFT JOIN actor.usr_address b ON (p.billing_address = b.id);
258
259 CREATE OR REPLACE FUNCTION action.age_circ_on_delete () RETURNS TRIGGER AS $$
260 DECLARE
261 found char := 'N';
262 BEGIN
263
264     -- If there are any renewals for this circulation, don't archive or delete
265     -- it yet.   We'll do so later, when we archive and delete the renewals.
266
267     SELECT 'Y' INTO found
268     FROM action.circulation
269     WHERE parent_circ = OLD.id
270     LIMIT 1;
271
272     IF found = 'Y' THEN
273         RETURN NULL;  -- don't delete
274         END IF;
275
276     -- Archive a copy of the old row to action.aged_circulation
277
278     INSERT INTO action.aged_circulation
279         (id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
280         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
281         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
282         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
283         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
284         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ)
285       SELECT
286         id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
287         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
288         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
289         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
290         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
291         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ
292         FROM action.all_circulation WHERE id = OLD.id;
293
294     RETURN OLD;
295 END;
296 $$ LANGUAGE 'plpgsql';
297
298 CREATE TRIGGER action_circulation_aging_tgr
299         BEFORE DELETE ON action.circulation
300         FOR EACH ROW
301         EXECUTE PROCEDURE action.age_circ_on_delete ();
302
303
304 CREATE OR REPLACE FUNCTION action.age_parent_circ_on_delete () RETURNS TRIGGER AS $$
305 BEGIN
306
307     -- Having deleted a renewal, we can delete the original circulation (or a previous
308     -- renewal, if that's what parent_circ is pointing to).  That deletion will trigger
309     -- deletion of any prior parents, etc. recursively.
310
311     IF OLD.parent_circ IS NOT NULL THEN
312         DELETE FROM action.circulation
313         WHERE id = OLD.parent_circ;
314     END IF;
315
316     RETURN OLD;
317 END;
318 $$ LANGUAGE 'plpgsql';
319
320 CREATE TRIGGER age_parent_circ
321         AFTER DELETE ON action.circulation
322         FOR EACH ROW
323         EXECUTE PROCEDURE action.age_parent_circ_on_delete ();
324
325
326 CREATE OR REPLACE VIEW action.open_circulation AS
327         SELECT  *
328           FROM  action.circulation
329           WHERE checkin_time IS NULL
330           ORDER BY due_date;
331                 
332
333 CREATE OR REPLACE VIEW action.billable_circulations AS
334         SELECT  *
335           FROM  action.circulation
336           WHERE xact_finish IS NULL;
337
338 CREATE OR REPLACE FUNCTION action.circulation_claims_returned () RETURNS TRIGGER AS $$
339 BEGIN
340         IF OLD.stop_fines IS NULL OR OLD.stop_fines <> NEW.stop_fines THEN
341                 IF NEW.stop_fines = 'CLAIMSRETURNED' THEN
342                         UPDATE actor.usr SET claims_returned_count = claims_returned_count + 1 WHERE id = NEW.usr;
343                 END IF;
344                 IF NEW.stop_fines = 'CLAIMSNEVERCHECKEDOUT' THEN
345                         UPDATE actor.usr SET claims_never_checked_out_count = claims_never_checked_out_count + 1 WHERE id = NEW.usr;
346                 END IF;
347                 IF NEW.stop_fines = 'LOST' THEN
348                         UPDATE asset.copy SET status = 3 WHERE id = NEW.target_copy;
349                 END IF;
350         END IF;
351         RETURN NEW;
352 END;
353 $$ LANGUAGE 'plpgsql';
354 CREATE TRIGGER action_circulation_stop_fines_tgr
355         BEFORE UPDATE ON action.circulation
356         FOR EACH ROW
357         EXECUTE PROCEDURE action.circulation_claims_returned ();
358
359 CREATE TABLE action.hold_request_cancel_cause (
360     id      SERIAL  PRIMARY KEY,
361     label   TEXT    UNIQUE
362 );
363
364 CREATE TABLE action.hold_request (
365         id                      SERIAL                          PRIMARY KEY,
366         request_time            TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
367         capture_time            TIMESTAMP WITH TIME ZONE,
368         fulfillment_time        TIMESTAMP WITH TIME ZONE,
369         checkin_time            TIMESTAMP WITH TIME ZONE,
370         return_time             TIMESTAMP WITH TIME ZONE,
371         prev_check_time         TIMESTAMP WITH TIME ZONE,
372         expire_time             TIMESTAMP WITH TIME ZONE,
373         cancel_time             TIMESTAMP WITH TIME ZONE,
374         cancel_cause    INT REFERENCES action.hold_request_cancel_cause (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
375         cancel_note             TEXT,
376         target                  BIGINT                          NOT NULL, -- see hold_type
377         current_copy            BIGINT,                         -- REFERENCES asset.copy (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,  -- XXX could be an serial.unit now...
378         fulfillment_staff       INT                             REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
379         fulfillment_lib         INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
380         request_lib             INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
381         requestor               INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
382         usr                     INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
383         selection_ou            INT                             NOT NULL,
384         selection_depth         INT                             NOT NULL DEFAULT 0,
385         pickup_lib              INT                             NOT NULL REFERENCES actor.org_unit DEFERRABLE INITIALLY DEFERRED,
386         hold_type               TEXT                            NOT NULL, -- CHECK (hold_type IN ('M','T','V','C')),  -- XXX constraint too constraining...
387         holdable_formats        TEXT,
388         phone_notify            TEXT,
389         email_notify            BOOL                            NOT NULL DEFAULT FALSE,
390         sms_notify              TEXT,
391         sms_carrier             INT REFERENCES config.sms_carrier (id),
392         frozen                  BOOL                            NOT NULL DEFAULT FALSE,
393         thaw_date               TIMESTAMP WITH TIME ZONE,
394         shelf_time              TIMESTAMP WITH TIME ZONE,
395     cut_in_line     BOOL,
396         mint_condition  BOOL NOT NULL DEFAULT TRUE,
397         shelf_expire_time TIMESTAMPTZ,
398         current_shelf_lib INT REFERENCES actor.org_unit DEFERRABLE INITIALLY DEFERRED,
399     behind_desk BOOLEAN NOT NULL DEFAULT FALSE
400 );
401 ALTER TABLE action.hold_request ADD CONSTRAINT sms_check CHECK (
402     sms_notify IS NULL
403     OR sms_carrier IS NOT NULL -- and implied sms_notify IS NOT NULL
404 );
405
406
407 CREATE INDEX hold_request_target_idx ON action.hold_request (target);
408 CREATE INDEX hold_request_usr_idx ON action.hold_request (usr);
409 CREATE INDEX hold_request_pickup_lib_idx ON action.hold_request (pickup_lib);
410 CREATE INDEX hold_request_current_copy_idx ON action.hold_request (current_copy);
411 CREATE INDEX hold_request_prev_check_time_idx ON action.hold_request (prev_check_time);
412 CREATE INDEX hold_request_fulfillment_staff_idx ON action.hold_request ( fulfillment_staff );
413 CREATE INDEX hold_request_requestor_idx         ON action.hold_request ( requestor );
414 CREATE INDEX hold_request_open_idx ON action.hold_request (id) WHERE cancel_time IS NULL AND fulfillment_time IS NULL;
415 CREATE INDEX hold_request_current_copy_before_cap_idx ON action.hold_request (current_copy) WHERE capture_time IS NULL AND cancel_time IS NULL;
416
417
418 CREATE TABLE action.hold_request_note (
419
420     id     BIGSERIAL PRIMARY KEY,
421     hold   BIGINT    NOT NULL REFERENCES action.hold_request (id)
422                               ON DELETE CASCADE
423                               DEFERRABLE INITIALLY DEFERRED,
424     title  TEXT      NOT NULL,
425     body   TEXT      NOT NULL,
426     slip   BOOL      NOT NULL DEFAULT FALSE,
427     pub    BOOL      NOT NULL DEFAULT FALSE,
428     staff  BOOL      NOT NULL DEFAULT FALSE  -- created by staff
429
430 );
431 CREATE INDEX ahrn_hold_idx ON action.hold_request_note (hold);
432
433
434 CREATE TABLE action.hold_notification (
435         id              SERIAL                          PRIMARY KEY,
436         hold            INT                             NOT NULL REFERENCES action.hold_request (id)
437                                                                         ON DELETE CASCADE
438                                                                         DEFERRABLE INITIALLY DEFERRED,
439         notify_staff    INT                     REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
440         notify_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
441         method          TEXT                            NOT NULL, -- email address or phone number
442         note            TEXT
443 );
444 CREATE INDEX ahn_hold_idx ON action.hold_notification (hold);
445 CREATE INDEX ahn_notify_staff_idx ON action.hold_notification ( notify_staff );
446
447 CREATE TABLE action.hold_copy_map (
448         id              BIGSERIAL       PRIMARY KEY,
449         hold            INT     NOT NULL REFERENCES action.hold_request (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
450         target_copy     BIGINT  NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
451         proximity       NUMERIC,
452         CONSTRAINT copy_once_per_hold UNIQUE (hold,target_copy)
453 );
454 -- CREATE INDEX acm_hold_idx ON action.hold_copy_map (hold);
455 CREATE INDEX acm_copy_idx ON action.hold_copy_map (target_copy);
456
457 CREATE TABLE action.transit_copy (
458         id                      SERIAL                          PRIMARY KEY,
459         source_send_time        TIMESTAMP WITH TIME ZONE,
460         dest_recv_time          TIMESTAMP WITH TIME ZONE,
461         target_copy             BIGINT                          NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
462         source                  INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
463         dest                    INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
464         prev_hop                INT                             REFERENCES action.transit_copy (id) DEFERRABLE INITIALLY DEFERRED,
465         copy_status             INT                             NOT NULL REFERENCES config.copy_status (id) DEFERRABLE INITIALLY DEFERRED,
466         persistant_transfer     BOOL                            NOT NULL DEFAULT FALSE,
467         prev_dest       INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED
468 );
469 CREATE INDEX active_transit_dest_idx ON "action".transit_copy (dest); 
470 CREATE INDEX active_transit_source_idx ON "action".transit_copy (source);
471 CREATE INDEX active_transit_cp_idx ON "action".transit_copy (target_copy);
472
473
474 CREATE TABLE action.hold_transit_copy (
475         hold    INT     REFERENCES action.hold_request (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED
476 ) INHERITS (action.transit_copy);
477 ALTER TABLE action.hold_transit_copy ADD PRIMARY KEY (id);
478 -- ALTER TABLE action.hold_transit_copy ADD CONSTRAINT ahtc_tc_fkey FOREIGN KEY (target_copy) REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; -- XXX could be an serial.issuance
479 CREATE INDEX active_hold_transit_dest_idx ON "action".hold_transit_copy (dest);
480 CREATE INDEX active_hold_transit_source_idx ON "action".hold_transit_copy (source);
481 CREATE INDEX active_hold_transit_cp_idx ON "action".hold_transit_copy (target_copy);
482 CREATE INDEX hold_transit_copy_hold_idx on action.hold_transit_copy (hold);
483
484
485 CREATE TABLE action.unfulfilled_hold_list (
486         id              BIGSERIAL                       PRIMARY KEY,
487         current_copy    BIGINT                          NOT NULL,
488         hold            INT                             NOT NULL,
489         circ_lib        INT                             NOT NULL,
490         fail_time       TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
491 );
492 CREATE INDEX uhr_hold_idx ON action.unfulfilled_hold_list (hold);
493
494 CREATE OR REPLACE VIEW action.unfulfilled_hold_loops AS
495     SELECT  u.hold,
496             c.circ_lib,
497             count(*)
498       FROM  action.unfulfilled_hold_list u
499             JOIN asset.copy c ON (c.id = u.current_copy)
500       GROUP BY 1,2;
501
502 CREATE OR REPLACE VIEW action.unfulfilled_hold_min_loop AS
503     SELECT  hold,
504             min(count)
505       FROM  action.unfulfilled_hold_loops
506       GROUP BY 1;
507
508 CREATE OR REPLACE VIEW action.unfulfilled_hold_innermost_loop AS
509     SELECT  DISTINCT l.*
510       FROM  action.unfulfilled_hold_loops l
511             JOIN action.unfulfilled_hold_min_loop m USING (hold)
512       WHERE l.count = m.min;
513
514 CREATE VIEW action.unfulfilled_hold_max_loop AS
515     SELECT  hold,
516             max(count) AS max
517       FROM  action.unfulfilled_hold_loops
518       GROUP BY 1;
519
520
521 CREATE TABLE action.aged_hold_request (
522     usr_post_code               TEXT,
523     usr_home_ou         INT     NOT NULL,
524     usr_profile         INT     NOT NULL,
525     usr_birth_year              INT,
526     staff_placed        BOOLEAN NOT NULL,
527     LIKE action.hold_request
528 );
529 ALTER TABLE action.aged_hold_request
530       ADD PRIMARY KEY (id),
531       DROP COLUMN usr,
532       DROP COLUMN requestor,
533       DROP COLUMN sms_carrier,
534       ALTER COLUMN phone_notify TYPE BOOLEAN
535             USING CASE WHEN phone_notify IS NULL OR phone_notify = '' THEN FALSE ELSE TRUE END,
536       ALTER COLUMN sms_notify TYPE BOOLEAN
537             USING CASE WHEN sms_notify IS NULL OR sms_notify = '' THEN FALSE ELSE TRUE END,
538       ALTER COLUMN phone_notify SET NOT NULL,
539       ALTER COLUMN sms_notify SET NOT NULL;
540 CREATE INDEX aged_hold_request_target_idx ON action.aged_hold_request (target);
541 CREATE INDEX aged_hold_request_pickup_lib_idx ON action.aged_hold_request (pickup_lib);
542 CREATE INDEX aged_hold_request_current_copy_idx ON action.aged_hold_request (current_copy);
543 CREATE INDEX aged_hold_request_fulfillment_staff_idx ON action.aged_hold_request ( fulfillment_staff );
544
545 CREATE OR REPLACE VIEW action.all_hold_request AS
546     SELECT DISTINCT
547            COALESCE(a.post_code, b.post_code) AS usr_post_code,
548            p.home_ou AS usr_home_ou,
549            p.profile AS usr_profile,
550            EXTRACT(YEAR FROM p.dob)::INT AS usr_birth_year,
551            CAST(ahr.requestor <> ahr.usr AS BOOLEAN) AS staff_placed,
552            ahr.id,
553            ahr.request_time,
554            ahr.capture_time,
555            ahr.fulfillment_time,
556            ahr.checkin_time,
557            ahr.return_time,
558            ahr.prev_check_time,
559            ahr.expire_time,
560            ahr.cancel_time,
561            ahr.cancel_cause,
562            ahr.cancel_note,
563            ahr.target,
564            ahr.current_copy,
565            ahr.fulfillment_staff,
566            ahr.fulfillment_lib,
567            ahr.request_lib,
568            ahr.selection_ou,
569            ahr.selection_depth,
570            ahr.pickup_lib,
571            ahr.hold_type,
572            ahr.holdable_formats,
573            CASE
574            WHEN ahr.phone_notify IS NULL THEN FALSE
575            WHEN ahr.phone_notify = '' THEN FALSE
576            ELSE TRUE
577            END AS phone_notify,
578            ahr.email_notify,
579            CASE
580            WHEN ahr.sms_notify IS NULL THEN FALSE
581            WHEN ahr.sms_notify = '' THEN FALSE
582            ELSE TRUE
583            END AS sms_notify,
584            ahr.frozen,
585            ahr.thaw_date,
586            ahr.shelf_time,
587            ahr.cut_in_line,
588            ahr.mint_condition,
589            ahr.shelf_expire_time,
590            ahr.current_shelf_lib
591     FROM action.hold_request ahr
592          JOIN actor.usr p ON (ahr.usr = p.id)
593          LEFT JOIN actor.usr_address a ON (p.mailing_address = a.id)
594          LEFT JOIN actor.usr_address b ON (p.billing_address = b.id)
595     UNION ALL
596     SELECT 
597            usr_post_code,
598            usr_home_ou,
599            usr_profile,
600            usr_birth_year,
601            staff_placed,
602            id,
603            request_time,
604            capture_time,
605            fulfillment_time,
606            checkin_time,
607            return_time,
608            prev_check_time,
609            expire_time,
610            cancel_time,
611            cancel_cause,
612            cancel_note,
613            target,
614            current_copy,
615            fulfillment_staff,
616            fulfillment_lib,
617            request_lib,
618            selection_ou,
619            selection_depth,
620            pickup_lib,
621            hold_type,
622            holdable_formats,
623            phone_notify,
624            email_notify,
625            sms_notify,
626            frozen,
627            thaw_date,
628            shelf_time,
629            cut_in_line,
630            mint_condition,
631            shelf_expire_time,
632            current_shelf_lib
633     FROM action.aged_hold_request;
634
635 CREATE OR REPLACE FUNCTION action.age_hold_on_delete () RETURNS TRIGGER AS $$
636 DECLARE
637 BEGIN
638     -- Archive a copy of the old row to action.aged_hold_request
639
640     INSERT INTO action.aged_hold_request
641            (usr_post_code,
642             usr_home_ou,
643             usr_profile,
644             usr_birth_year,
645             staff_placed,
646             id,
647             request_time,
648             capture_time,
649             fulfillment_time,
650             checkin_time,
651             return_time,
652             prev_check_time,
653             expire_time,
654             cancel_time,
655             cancel_cause,
656             cancel_note,
657             target,
658             current_copy,
659             fulfillment_staff,
660             fulfillment_lib,
661             request_lib,
662             selection_ou,
663             selection_depth,
664             pickup_lib,
665             hold_type,
666             holdable_formats,
667             phone_notify,
668             email_notify,
669             sms_notify,
670             frozen,
671             thaw_date,
672             shelf_time,
673             cut_in_line,
674             mint_condition,
675             shelf_expire_time,
676             current_shelf_lib)
677       SELECT 
678            usr_post_code,
679            usr_home_ou,
680            usr_profile,
681            usr_birth_year,
682            staff_placed,
683            id,
684            request_time,
685            capture_time,
686            fulfillment_time,
687            checkin_time,
688            return_time,
689            prev_check_time,
690            expire_time,
691            cancel_time,
692            cancel_cause,
693            cancel_note,
694            target,
695            current_copy,
696            fulfillment_staff,
697            fulfillment_lib,
698            request_lib,
699            selection_ou,
700            selection_depth,
701            pickup_lib,
702            hold_type,
703            holdable_formats,
704            phone_notify,
705            email_notify,
706            sms_notify,
707            frozen,
708            thaw_date,
709            shelf_time,
710            cut_in_line,
711            mint_condition,
712            shelf_expire_time,
713            current_shelf_lib
714         FROM action.all_hold_request WHERE id = OLD.id;
715
716     RETURN OLD;
717 END;
718 $$ LANGUAGE 'plpgsql';
719
720 CREATE TRIGGER action_hold_request_aging_tgr
721         BEFORE DELETE ON action.hold_request
722         FOR EACH ROW
723         EXECUTE PROCEDURE action.age_hold_on_delete ();
724
725 CREATE TABLE action.fieldset (
726     id              SERIAL          PRIMARY KEY,
727     owner           INT             NOT NULL REFERENCES actor.usr (id)
728                                     DEFERRABLE INITIALLY DEFERRED,
729         owning_lib      INT             NOT NULL REFERENCES actor.org_unit (id)
730                                     DEFERRABLE INITIALLY DEFERRED,
731         status          TEXT            NOT NULL
732                                         CONSTRAINT valid_status CHECK ( status in
733                                                                         ( 'PENDING', 'APPLIED', 'ERROR' )),
734     creation_time   TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
735     scheduled_time  TIMESTAMPTZ,
736     applied_time    TIMESTAMPTZ,
737     classname       TEXT            NOT NULL, -- an IDL class name
738     name            TEXT            NOT NULL,
739     stored_query    INT             REFERENCES query.stored_query (id)
740                                     DEFERRABLE INITIALLY DEFERRED,
741     pkey_value      TEXT,
742         CONSTRAINT lib_name_unique UNIQUE (owning_lib, name),
743     CONSTRAINT fieldset_one_or_the_other CHECK (
744         (stored_query IS NOT NULL AND pkey_value IS NULL) OR
745         (pkey_value IS NOT NULL AND stored_query IS NULL)
746     )
747         -- the CHECK constraint means we can update the fields for a single
748         -- row without all the extra overhead involved in a query
749 );
750
751 CREATE INDEX action_fieldset_sched_time_idx ON action.fieldset( scheduled_time );
752 CREATE INDEX action_owner_idx               ON action.fieldset( owner );
753
754
755 CREATE TABLE action.fieldset_col_val (
756     id              SERIAL  PRIMARY KEY,
757     fieldset        INT     NOT NULL REFERENCES action.fieldset
758                                          ON DELETE CASCADE
759                                          DEFERRABLE INITIALLY DEFERRED,
760     col             TEXT    NOT NULL,  -- "field" from the idl ... the column on the table
761     val             TEXT,              -- value for the column ... NULL means, well, NULL
762     CONSTRAINT fieldset_col_once_per_set UNIQUE (fieldset, col)
763 );
764
765
766 -- represents a circ chain summary
767 CREATE TYPE action.circ_chain_summary AS (
768     num_circs INTEGER,
769     start_time TIMESTAMP WITH TIME ZONE,
770     checkout_workstation TEXT,
771     last_renewal_time TIMESTAMP WITH TIME ZONE, -- NULL if no renewals
772     last_stop_fines TEXT,
773     last_stop_fines_time TIMESTAMP WITH TIME ZONE,
774     last_renewal_workstation TEXT, -- NULL if no renewals
775     last_checkin_workstation TEXT,
776     last_checkin_time TIMESTAMP WITH TIME ZONE,
777     last_checkin_scan_time TIMESTAMP WITH TIME ZONE
778 );
779
780
781 CREATE OR REPLACE FUNCTION action.circ_chain ( ctx_circ_id BIGINT ) RETURNS SETOF action.circulation AS $$
782 DECLARE
783     tmp_circ action.circulation%ROWTYPE;
784     circ_0 action.circulation%ROWTYPE;
785 BEGIN
786
787     SELECT INTO tmp_circ * FROM action.circulation WHERE id = ctx_circ_id;
788
789     IF tmp_circ IS NULL THEN
790         RETURN NEXT tmp_circ;
791     END IF;
792     circ_0 := tmp_circ;
793
794     -- find the front of the chain
795     WHILE TRUE LOOP
796         SELECT INTO tmp_circ * FROM action.circulation WHERE id = tmp_circ.parent_circ;
797         IF tmp_circ IS NULL THEN
798             EXIT;
799         END IF;
800         circ_0 := tmp_circ;
801     END LOOP;
802
803     -- now send the circs to the caller, oldest to newest
804     tmp_circ := circ_0;
805     WHILE TRUE LOOP
806         IF tmp_circ IS NULL THEN
807             EXIT;
808         END IF;
809         RETURN NEXT tmp_circ;
810         SELECT INTO tmp_circ * FROM action.circulation WHERE parent_circ = tmp_circ.id;
811     END LOOP;
812
813 END;
814 $$ LANGUAGE 'plpgsql';
815
816 CREATE OR REPLACE FUNCTION action.summarize_circ_chain ( ctx_circ_id BIGINT ) RETURNS action.circ_chain_summary AS $$
817
818 DECLARE
819
820     -- first circ in the chain
821     circ_0 action.circulation%ROWTYPE;
822
823     -- last circ in the chain
824     circ_n action.circulation%ROWTYPE;
825
826     -- circ chain under construction
827     chain action.circ_chain_summary;
828     tmp_circ action.circulation%ROWTYPE;
829
830 BEGIN
831     
832     chain.num_circs := 0;
833     FOR tmp_circ IN SELECT * FROM action.circ_chain(ctx_circ_id) LOOP
834
835         IF chain.num_circs = 0 THEN
836             circ_0 := tmp_circ;
837         END IF;
838
839         chain.num_circs := chain.num_circs + 1;
840         circ_n := tmp_circ;
841     END LOOP;
842
843     chain.start_time := circ_0.xact_start;
844     chain.last_stop_fines := circ_n.stop_fines;
845     chain.last_stop_fines_time := circ_n.stop_fines_time;
846     chain.last_checkin_time := circ_n.checkin_time;
847     chain.last_checkin_scan_time := circ_n.checkin_scan_time;
848     SELECT INTO chain.checkout_workstation name FROM actor.workstation WHERE id = circ_0.workstation;
849     SELECT INTO chain.last_checkin_workstation name FROM actor.workstation WHERE id = circ_n.checkin_workstation;
850
851     IF chain.num_circs > 1 THEN
852         chain.last_renewal_time := circ_n.xact_start;
853         SELECT INTO chain.last_renewal_workstation name FROM actor.workstation WHERE id = circ_n.workstation;
854     END IF;
855
856     RETURN chain;
857
858 END;
859 $$ LANGUAGE 'plpgsql';
860
861 -- Return the list of circ chain heads in xact_start order that the user has chosen to "retain"
862 CREATE OR REPLACE FUNCTION action.usr_visible_circs (usr_id INT) RETURNS SETOF action.circulation AS $func$
863 DECLARE
864     c               action.circulation%ROWTYPE;
865     view_age        INTERVAL;
866     usr_view_age    actor.usr_setting%ROWTYPE;
867     usr_view_start  actor.usr_setting%ROWTYPE;
868 BEGIN
869     SELECT * INTO usr_view_age FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.circ.retention_age';
870     SELECT * INTO usr_view_start FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.circ.retention_start';
871
872     IF usr_view_age.value IS NOT NULL AND usr_view_start.value IS NOT NULL THEN
873         -- User opted in and supplied a retention age
874         IF oils_json_to_text(usr_view_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ) THEN
875             view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
876         ELSE
877             view_age := oils_json_to_text(usr_view_age.value)::INTERVAL;
878         END IF;
879     ELSIF usr_view_start.value IS NOT NULL THEN
880         -- User opted in
881         view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
882     ELSE
883         -- User did not opt in
884         RETURN;
885     END IF;
886
887     FOR c IN
888         SELECT  *
889           FROM  action.circulation
890           WHERE usr = usr_id
891                 AND parent_circ IS NULL
892                 AND xact_start > NOW() - view_age
893           ORDER BY xact_start DESC
894     LOOP
895         RETURN NEXT c;
896     END LOOP;
897
898     RETURN;
899 END;
900 $func$ LANGUAGE PLPGSQL;
901
902 CREATE OR REPLACE FUNCTION action.usr_visible_circ_copies( INTEGER ) RETURNS SETOF BIGINT AS $$
903     SELECT DISTINCT(target_copy) FROM action.usr_visible_circs($1)
904 $$ LANGUAGE SQL ROWS 10;
905
906 CREATE OR REPLACE FUNCTION action.usr_visible_holds (usr_id INT) RETURNS SETOF action.hold_request AS $func$
907 DECLARE
908     h               action.hold_request%ROWTYPE;
909     view_age        INTERVAL;
910     view_count      INT;
911     usr_view_count  actor.usr_setting%ROWTYPE;
912     usr_view_age    actor.usr_setting%ROWTYPE;
913     usr_view_start  actor.usr_setting%ROWTYPE;
914 BEGIN
915     SELECT * INTO usr_view_count FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_count';
916     SELECT * INTO usr_view_age FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_age';
917     SELECT * INTO usr_view_start FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_start';
918
919     FOR h IN
920         SELECT  *
921           FROM  action.hold_request
922           WHERE usr = usr_id
923                 AND fulfillment_time IS NULL
924                 AND cancel_time IS NULL
925           ORDER BY request_time DESC
926     LOOP
927         RETURN NEXT h;
928     END LOOP;
929
930     IF usr_view_start.value IS NULL THEN
931         RETURN;
932     END IF;
933
934     IF usr_view_age.value IS NOT NULL THEN
935         -- User opted in and supplied a retention age
936         IF oils_json_to_text(usr_view_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ) THEN
937             view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
938         ELSE
939             view_age := oils_json_to_text(usr_view_age.value)::INTERVAL;
940         END IF;
941     ELSE
942         -- User opted in
943         view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
944     END IF;
945
946     IF usr_view_count.value IS NOT NULL THEN
947         view_count := oils_json_to_text(usr_view_count.value)::INT;
948     ELSE
949         view_count := 1000;
950     END IF;
951
952     -- show some fulfilled/canceled holds
953     FOR h IN
954         SELECT  *
955           FROM  action.hold_request
956           WHERE usr = usr_id
957                 AND ( fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL )
958                 AND COALESCE(fulfillment_time, cancel_time) > NOW() - view_age
959           ORDER BY COALESCE(fulfillment_time, cancel_time) DESC
960           LIMIT view_count
961     LOOP
962         RETURN NEXT h;
963     END LOOP;
964
965     RETURN;
966 END;
967 $func$ LANGUAGE PLPGSQL;
968
969 CREATE OR REPLACE FUNCTION action.purge_circulations () RETURNS INT AS $func$
970 DECLARE
971     usr_keep_age    actor.usr_setting%ROWTYPE;
972     usr_keep_start  actor.usr_setting%ROWTYPE;
973     org_keep_age    INTERVAL;
974     org_use_last    BOOL = false;
975     org_age_is_min  BOOL = false;
976     org_keep_count  INT;
977
978     keep_age        INTERVAL;
979
980     target_acp      RECORD;
981     circ_chain_head action.circulation%ROWTYPE;
982     circ_chain_tail action.circulation%ROWTYPE;
983
984     count_purged    INT;
985     num_incomplete  INT;
986
987     last_finished   TIMESTAMP WITH TIME ZONE;
988 BEGIN
989
990     count_purged := 0;
991
992     SELECT value::INTERVAL INTO org_keep_age FROM config.global_flag WHERE name = 'history.circ.retention_age' AND enabled;
993
994     SELECT value::INT INTO org_keep_count FROM config.global_flag WHERE name = 'history.circ.retention_count' AND enabled;
995     IF org_keep_count IS NULL THEN
996         RETURN count_purged; -- Gimme a count to keep, or I keep them all, forever
997     END IF;
998
999     SELECT enabled INTO org_use_last FROM config.global_flag WHERE name = 'history.circ.retention_uses_last_finished';
1000     SELECT enabled INTO org_age_is_min FROM config.global_flag WHERE name = 'history.circ.retention_age_is_min';
1001
1002     -- First, find copies with more than keep_count non-renewal circs
1003     FOR target_acp IN
1004         SELECT  target_copy,
1005                 COUNT(*) AS total_real_circs
1006           FROM  action.circulation
1007           WHERE parent_circ IS NULL
1008                 AND xact_finish IS NOT NULL
1009           GROUP BY target_copy
1010           HAVING COUNT(*) > org_keep_count
1011     LOOP
1012         -- And, for those, select circs that are finished and older than keep_age
1013         FOR circ_chain_head IN
1014             -- For reference, the subquery uses a window function to order the circs newest to oldest and number them
1015             -- The outer query then uses that information to skip the most recent set the library wants to keep
1016             -- End result is we don't care what order they come out in, as they are all potentials for deletion.
1017             SELECT ac.* FROM action.circulation ac JOIN (
1018               SELECT  rank() OVER (ORDER BY xact_start DESC), ac.id
1019                 FROM  action.circulation ac
1020                 WHERE ac.target_copy = target_acp.target_copy
1021                   AND ac.parent_circ IS NULL
1022                 ORDER BY ac.xact_start ) ranked USING (id)
1023                 WHERE ranked.rank > org_keep_count
1024         LOOP
1025
1026             SELECT * INTO circ_chain_tail FROM action.circ_chain(circ_chain_head.id) ORDER BY xact_start DESC LIMIT 1;
1027             SELECT COUNT(CASE WHEN xact_finish IS NULL THEN 1 ELSE NULL END), MAX(xact_finish) INTO num_incomplete, last_finished FROM action.circ_chain(circ_chain_head.id);
1028             CONTINUE WHEN circ_chain_tail.xact_finish IS NULL OR num_incomplete > 0;
1029
1030             IF NOT org_use_last THEN
1031                 last_finished := circ_chain_tail.xact_finish;
1032             END IF;
1033
1034             -- Now get the user settings, if any, to block purging if the user wants to keep more circs
1035             usr_keep_age.value := NULL;
1036             SELECT * INTO usr_keep_age FROM actor.usr_setting WHERE usr = circ_chain_head.usr AND name = 'history.circ.retention_age';
1037
1038             usr_keep_start.value := NULL;
1039             SELECT * INTO usr_keep_start FROM actor.usr_setting WHERE usr = circ_chain_head.usr AND name = 'history.circ.retention_start';
1040
1041             IF usr_keep_age.value IS NOT NULL AND usr_keep_start.value IS NOT NULL THEN
1042                 IF oils_json_to_text(usr_keep_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ) THEN
1043                     keep_age := AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ);
1044                 ELSE
1045                     keep_age := oils_json_to_text(usr_keep_age.value)::INTERVAL;
1046                 END IF;
1047             ELSIF usr_keep_start.value IS NOT NULL THEN
1048                 keep_age := AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ);
1049             ELSE
1050                 keep_age := COALESCE( org_keep_age, '2000 years'::INTERVAL );
1051             END IF;
1052
1053             IF org_age_is_min THEN
1054                 keep_age := GREATEST( keep_age, org_keep_age );
1055             END IF;
1056
1057             CONTINUE WHEN AGE(NOW(), last_finished) < keep_age;
1058
1059             -- We've passed the purging tests, purge the circ chain starting at the end
1060             -- A trigger should auto-purge the rest of the chain.
1061             DELETE FROM action.circulation WHERE id = circ_chain_tail.id;
1062
1063             count_purged := count_purged + 1;
1064
1065         END LOOP;
1066     END LOOP;
1067
1068     return count_purged;
1069 END;
1070 $func$ LANGUAGE PLPGSQL;
1071
1072 CREATE OR REPLACE FUNCTION action.purge_holds() RETURNS INT AS $func$
1073 DECLARE
1074   current_hold RECORD;
1075   purged_holds INT;
1076   cgf_d INTERVAL;
1077   cgf_f INTERVAL;
1078   cgf_c INTERVAL;
1079   prev_usr INT;
1080   user_start TIMESTAMPTZ;
1081   user_age INTERVAL;
1082   user_count INT;
1083 BEGIN
1084   purged_holds := 0;
1085   SELECT INTO cgf_d value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age' AND enabled;
1086   SELECT INTO cgf_f value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age_fulfilled' AND enabled;
1087   SELECT INTO cgf_c value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age_canceled' AND enabled;
1088   FOR current_hold IN
1089     SELECT
1090       rank() OVER (PARTITION BY usr ORDER BY COALESCE(fulfillment_time, cancel_time) DESC),
1091       cgf_cs.value::INTERVAL as cgf_cs,
1092       ahr.*
1093     FROM
1094       action.hold_request ahr
1095       LEFT JOIN config.global_flag cgf_cs ON (ahr.cancel_cause IS NOT NULL AND cgf_cs.name = 'history.hold.retention_age_canceled_' || ahr.cancel_cause AND cgf_cs.enabled)
1096     WHERE
1097       (fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL)
1098   LOOP
1099     IF prev_usr IS NULL OR prev_usr != current_hold.usr THEN
1100       prev_usr := current_hold.usr;
1101       SELECT INTO user_start oils_json_to_text(value)::TIMESTAMPTZ FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_start';
1102       SELECT INTO user_age oils_json_to_text(value)::INTERVAL FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_age';
1103       SELECT INTO user_count oils_json_to_text(value)::INT FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_count';
1104       IF user_start IS NOT NULL THEN
1105         user_age := LEAST(user_age, AGE(NOW(), user_start));
1106       END IF;
1107       IF user_count IS NULL THEN
1108         user_count := 1000; -- Assumption based on the user visible holds routine
1109       END IF;
1110     END IF;
1111     -- Library keep age trumps user keep anything, for purposes of being able to hold on to things when staff canceled and such.
1112     IF current_hold.fulfillment_time IS NOT NULL AND current_hold.fulfillment_time > NOW() - COALESCE(cgf_f, cgf_d) THEN
1113       CONTINUE;
1114     END IF;
1115     IF current_hold.cancel_time IS NOT NULL AND current_hold.cancel_time > NOW() - COALESCE(current_hold.cgf_cs, cgf_c, cgf_d) THEN
1116       CONTINUE;
1117     END IF;
1118
1119     -- User keep age needs combining with count. If too old AND within the count, keep!
1120     IF user_start IS NOT NULL AND COALESCE(current_hold.fulfillment_time, current_hold.cancel_time) > NOW() - user_age AND current_hold.rank <= user_count THEN
1121       CONTINUE;
1122     END IF;
1123
1124     -- All checks should have passed, delete!
1125     DELETE FROM action.hold_request WHERE id = current_hold.id;
1126     purged_holds := purged_holds + 1;
1127   END LOOP;
1128   RETURN purged_holds;
1129 END;
1130 $func$ LANGUAGE plpgsql;
1131
1132 CREATE OR REPLACE FUNCTION action.apply_fieldset(
1133         fieldset_id IN INT,        -- id from action.fieldset
1134         table_name  IN TEXT,       -- table to be updated
1135         pkey_name   IN TEXT,       -- name of primary key column in that table
1136         query       IN TEXT        -- query constructed by qstore (for query-based
1137                                    --    fieldsets only; otherwise null
1138 )
1139 RETURNS TEXT AS $$
1140 DECLARE
1141         statement TEXT;
1142         fs_status TEXT;
1143         fs_pkey_value TEXT;
1144         fs_query TEXT;
1145         sep CHAR;
1146         status_code TEXT;
1147         msg TEXT;
1148         update_count INT;
1149         cv RECORD;
1150 BEGIN
1151         -- Sanity checks
1152         IF fieldset_id IS NULL THEN
1153                 RETURN 'Fieldset ID parameter is NULL';
1154         END IF;
1155         IF table_name IS NULL THEN
1156                 RETURN 'Table name parameter is NULL';
1157         END IF;
1158         IF pkey_name IS NULL THEN
1159                 RETURN 'Primary key name parameter is NULL';
1160         END IF;
1161         --
1162         statement := 'UPDATE ' || table_name || ' SET';
1163         --
1164         SELECT
1165                 status,
1166                 quote_literal( pkey_value )
1167         INTO
1168                 fs_status,
1169                 fs_pkey_value
1170         FROM
1171                 action.fieldset
1172         WHERE
1173                 id = fieldset_id;
1174         --
1175         IF fs_status IS NULL THEN
1176                 RETURN 'No fieldset found for id = ' || fieldset_id;
1177         ELSIF fs_status = 'APPLIED' THEN
1178                 RETURN 'Fieldset ' || fieldset_id || ' has already been applied';
1179         END IF;
1180         --
1181         sep := '';
1182         FOR cv IN
1183                 SELECT  col,
1184                                 val
1185                 FROM    action.fieldset_col_val
1186                 WHERE   fieldset = fieldset_id
1187         LOOP
1188                 statement := statement || sep || ' ' || cv.col
1189                                          || ' = ' || coalesce( quote_literal( cv.val ), 'NULL' );
1190                 sep := ',';
1191         END LOOP;
1192         --
1193         IF sep = '' THEN
1194                 RETURN 'Fieldset ' || fieldset_id || ' has no column values defined';
1195         END IF;
1196         --
1197         -- Add the WHERE clause.  This differs according to whether it's a
1198         -- single-row fieldset or a query-based fieldset.
1199         --
1200         IF query IS NULL        AND fs_pkey_value IS NULL THEN
1201                 RETURN 'Incomplete fieldset: neither a primary key nor a query available';
1202         ELSIF query IS NOT NULL AND fs_pkey_value IS NULL THEN
1203             fs_query := rtrim( query, ';' );
1204             statement := statement || ' WHERE ' || pkey_name || ' IN ( '
1205                          || fs_query || ' );';
1206         ELSIF query IS NULL     AND fs_pkey_value IS NOT NULL THEN
1207                 statement := statement || ' WHERE ' || pkey_name || ' = '
1208                                      || fs_pkey_value || ';';
1209         ELSE  -- both are not null
1210                 RETURN 'Ambiguous fieldset: both a primary key and a query provided';
1211         END IF;
1212         --
1213         -- Execute the update
1214         --
1215         BEGIN
1216                 EXECUTE statement;
1217                 GET DIAGNOSTICS update_count = ROW_COUNT;
1218                 --
1219                 IF UPDATE_COUNT > 0 THEN
1220                         status_code := 'APPLIED';
1221                         msg := NULL;
1222                 ELSE
1223                         status_code := 'ERROR';
1224                         msg := 'No eligible rows found for fieldset ' || fieldset_id;
1225         END IF;
1226         EXCEPTION WHEN OTHERS THEN
1227                 status_code := 'ERROR';
1228                 msg := 'Unable to apply fieldset ' || fieldset_id
1229                            || ': ' || sqlerrm;
1230         END;
1231         --
1232         -- Update fieldset status
1233         --
1234         UPDATE action.fieldset
1235         SET status       = status_code,
1236             applied_time = now()
1237         WHERE id = fieldset_id;
1238         --
1239         RETURN msg;
1240 END;
1241 $$ LANGUAGE plpgsql;
1242
1243 COMMENT ON FUNCTION action.apply_fieldset( INT, TEXT, TEXT, TEXT ) IS $$
1244 Applies a specified fieldset, using a supplied table name and primary
1245 key name.  The query parameter should be non-null only for
1246 query-based fieldsets.
1247
1248 Returns NULL if successful, or an error message if not.
1249 $$;
1250
1251 CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity(
1252     ahr_id INT,
1253     acp_id BIGINT,
1254     copy_context_ou INT DEFAULT NULL
1255     -- TODO maybe? hold_context_ou INT DEFAULT NULL.  This would optionally
1256     -- support an "ahprox" measurement: adjust prox between copy circ lib and
1257     -- hold request lib, but I'm unsure whether to use this theoretical
1258     -- argument only in the baseline calculation or later in the other
1259     -- queries in this function.
1260 ) RETURNS NUMERIC AS $f$
1261 DECLARE
1262     aoupa           actor.org_unit_proximity_adjustment%ROWTYPE;
1263     ahr             action.hold_request%ROWTYPE;
1264     acp             asset.copy%ROWTYPE;
1265     acn             asset.call_number%ROWTYPE;
1266     acl             asset.copy_location%ROWTYPE;
1267     baseline_prox   NUMERIC;
1268
1269     icl_list        INT[];
1270     iol_list        INT[];
1271     isl_list        INT[];
1272     hpl_list        INT[];
1273     hrl_list        INT[];
1274
1275 BEGIN
1276
1277     SELECT * INTO ahr FROM action.hold_request WHERE id = ahr_id;
1278     SELECT * INTO acp FROM asset.copy WHERE id = acp_id;
1279     SELECT * INTO acn FROM asset.call_number WHERE id = acp.call_number;
1280     SELECT * INTO acl FROM asset.copy_location WHERE id = acp.location;
1281
1282     IF copy_context_ou IS NULL THEN
1283         copy_context_ou := acp.circ_lib;
1284     END IF;
1285
1286     -- First, gather the baseline proximity of "here" to pickup lib
1287     SELECT prox INTO baseline_prox FROM actor.org_unit_proximity WHERE from_org = copy_context_ou AND to_org = ahr.pickup_lib;
1288
1289     -- Find any absolute adjustments, and set the baseline prox to that
1290     SELECT  adj.* INTO aoupa
1291       FROM  actor.org_unit_proximity_adjustment adj
1292             LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
1293             LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
1294             LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
1295             LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
1296             LEFT JOIN actor.org_unit_ancestors_distance(ahr.request_lib) ahr_rl ON (ahr_rl.id = adj.hold_request_lib)
1297       WHERE (adj.circ_mod IS NULL OR adj.circ_mod = acp.circ_modifier) AND
1298         absolute_adjustment AND
1299         COALESCE(acp_cl.id, acn_ol.id, acl_ol.id, ahr_pl.id, ahr_rl.id) IS NOT NULL
1300       ORDER BY
1301             COALESCE(acp_cl.distance,999)
1302                 + COALESCE(acn_ol.distance,999)
1303                 + COALESCE(acl_ol.distance,999)
1304                 + COALESCE(ahr_pl.distance,999)
1305                 + COALESCE(ahr_rl.distance,999),
1306             adj.pos
1307       LIMIT 1;
1308
1309     IF FOUND THEN
1310         baseline_prox := aoupa.prox_adjustment;
1311     END IF;
1312
1313     -- Now find any relative adjustments, and change the baseline prox based on them
1314     FOR aoupa IN
1315         SELECT  adj.* 
1316           FROM  actor.org_unit_proximity_adjustment adj
1317                 LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
1318                 LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
1319                 LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
1320                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
1321                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.request_lib) ahr_rl ON (ahr_rl.id = adj.hold_request_lib)
1322           WHERE (adj.circ_mod IS NULL OR adj.circ_mod = acp.circ_modifier) AND
1323             NOT absolute_adjustment AND
1324             COALESCE(acp_cl.id, acn_ol.id, acl_ol.id, ahr_pl.id, ahr_rl.id) IS NOT NULL
1325     LOOP
1326         baseline_prox := baseline_prox + aoupa.prox_adjustment;
1327     END LOOP;
1328
1329     RETURN baseline_prox;
1330 END;
1331 $f$ LANGUAGE PLPGSQL;
1332
1333 COMMIT;