]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/sql/Pg/090.schema.action.sql
LP#1596595 Hold targeter refactoring and optimization.
[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 CREATE INDEX action_aged_circulation_parent_circ_idx ON action.aged_circulation (parent_circ);
236
237 CREATE OR REPLACE VIEW action.all_circulation AS
238     SELECT  id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
239         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
240         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
241         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
242         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
243         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ,
244         NULL AS usr
245       FROM  action.aged_circulation
246             UNION ALL
247     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,
248         cp.call_number AS copy_call_number, circ.copy_location, cn.owning_lib AS copy_owning_lib, cp.circ_lib AS copy_circ_lib,
249         cn.record AS copy_bib_record, circ.xact_start, circ.xact_finish, circ.target_copy, circ.circ_lib, circ.circ_staff, circ.checkin_staff,
250         circ.checkin_lib, circ.renewal_remaining, circ.grace_period, circ.due_date, circ.stop_fines_time, circ.checkin_time, circ.create_time, circ.duration,
251         circ.fine_interval, circ.recurring_fine, circ.max_fine, circ.phone_renewal, circ.desk_renewal, circ.opac_renewal, circ.duration_rule,
252         circ.recurring_fine_rule, circ.max_fine_rule, circ.stop_fines, circ.workstation, circ.checkin_workstation, circ.checkin_scan_time,
253         circ.parent_circ, circ.usr
254       FROM  action.circulation circ
255         JOIN asset.copy cp ON (circ.target_copy = cp.id)
256         JOIN asset.call_number cn ON (cp.call_number = cn.id)
257         JOIN actor.usr p ON (circ.usr = p.id)
258         LEFT JOIN actor.usr_address a ON (p.mailing_address = a.id)
259         LEFT JOIN actor.usr_address b ON (p.billing_address = b.id);
260
261 CREATE OR REPLACE FUNCTION action.age_circ_on_delete () RETURNS TRIGGER AS $$
262 DECLARE
263 found char := 'N';
264 BEGIN
265
266     -- If there are any renewals for this circulation, don't archive or delete
267     -- it yet.   We'll do so later, when we archive and delete the renewals.
268
269     SELECT 'Y' INTO found
270     FROM action.circulation
271     WHERE parent_circ = OLD.id
272     LIMIT 1;
273
274     IF found = 'Y' THEN
275         RETURN NULL;  -- don't delete
276         END IF;
277
278     -- Archive a copy of the old row to action.aged_circulation
279
280     INSERT INTO action.aged_circulation
281         (id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
282         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
283         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
284         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
285         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
286         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ)
287       SELECT
288         id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
289         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
290         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
291         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
292         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
293         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ
294         FROM action.all_circulation WHERE id = OLD.id;
295
296     RETURN OLD;
297 END;
298 $$ LANGUAGE 'plpgsql';
299
300 CREATE TRIGGER action_circulation_aging_tgr
301         BEFORE DELETE ON action.circulation
302         FOR EACH ROW
303         EXECUTE PROCEDURE action.age_circ_on_delete ();
304
305
306 CREATE OR REPLACE FUNCTION action.age_parent_circ_on_delete () RETURNS TRIGGER AS $$
307 BEGIN
308
309     -- Having deleted a renewal, we can delete the original circulation (or a previous
310     -- renewal, if that's what parent_circ is pointing to).  That deletion will trigger
311     -- deletion of any prior parents, etc. recursively.
312
313     IF OLD.parent_circ IS NOT NULL THEN
314         DELETE FROM action.circulation
315         WHERE id = OLD.parent_circ;
316     END IF;
317
318     RETURN OLD;
319 END;
320 $$ LANGUAGE 'plpgsql';
321
322 CREATE TRIGGER age_parent_circ
323         AFTER DELETE ON action.circulation
324         FOR EACH ROW
325         EXECUTE PROCEDURE action.age_parent_circ_on_delete ();
326
327
328 CREATE OR REPLACE VIEW action.open_circulation AS
329         SELECT  *
330           FROM  action.circulation
331           WHERE checkin_time IS NULL
332           ORDER BY due_date;
333                 
334
335 CREATE OR REPLACE VIEW action.billable_circulations AS
336         SELECT  *
337           FROM  action.circulation
338           WHERE xact_finish IS NULL;
339
340 CREATE OR REPLACE FUNCTION action.circulation_claims_returned () RETURNS TRIGGER AS $$
341 BEGIN
342         IF OLD.stop_fines IS NULL OR OLD.stop_fines <> NEW.stop_fines THEN
343                 IF NEW.stop_fines = 'CLAIMSRETURNED' THEN
344                         UPDATE actor.usr SET claims_returned_count = claims_returned_count + 1 WHERE id = NEW.usr;
345                 END IF;
346                 IF NEW.stop_fines = 'CLAIMSNEVERCHECKEDOUT' THEN
347                         UPDATE actor.usr SET claims_never_checked_out_count = claims_never_checked_out_count + 1 WHERE id = NEW.usr;
348                 END IF;
349                 IF NEW.stop_fines = 'LOST' THEN
350                         UPDATE asset.copy SET status = 3 WHERE id = NEW.target_copy;
351                 END IF;
352         END IF;
353         RETURN NEW;
354 END;
355 $$ LANGUAGE 'plpgsql';
356 CREATE TRIGGER action_circulation_stop_fines_tgr
357         BEFORE UPDATE ON action.circulation
358         FOR EACH ROW
359         EXECUTE PROCEDURE action.circulation_claims_returned ();
360
361 CREATE TABLE action.hold_request_cancel_cause (
362     id      SERIAL  PRIMARY KEY,
363     label   TEXT    UNIQUE
364 );
365
366 CREATE TABLE action.hold_request (
367         id                      SERIAL                          PRIMARY KEY,
368         request_time            TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
369         capture_time            TIMESTAMP WITH TIME ZONE,
370         fulfillment_time        TIMESTAMP WITH TIME ZONE,
371         checkin_time            TIMESTAMP WITH TIME ZONE,
372         return_time             TIMESTAMP WITH TIME ZONE,
373         prev_check_time         TIMESTAMP WITH TIME ZONE,
374         expire_time             TIMESTAMP WITH TIME ZONE,
375         cancel_time             TIMESTAMP WITH TIME ZONE,
376         cancel_cause    INT REFERENCES action.hold_request_cancel_cause (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
377         cancel_note             TEXT,
378         target                  BIGINT                          NOT NULL, -- see hold_type
379         current_copy            BIGINT,                         -- REFERENCES asset.copy (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,  -- XXX could be an serial.unit now...
380         fulfillment_staff       INT                             REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
381         fulfillment_lib         INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
382         request_lib             INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
383         requestor               INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
384         usr                     INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
385         selection_ou            INT                             NOT NULL,
386         selection_depth         INT                             NOT NULL DEFAULT 0,
387         pickup_lib              INT                             NOT NULL REFERENCES actor.org_unit DEFERRABLE INITIALLY DEFERRED,
388         hold_type               TEXT                            NOT NULL, -- CHECK (hold_type IN ('M','T','V','C')),  -- XXX constraint too constraining...
389         holdable_formats        TEXT,
390         phone_notify            TEXT,
391         email_notify            BOOL                            NOT NULL DEFAULT FALSE,
392         sms_notify              TEXT,
393         sms_carrier             INT REFERENCES config.sms_carrier (id),
394         frozen                  BOOL                            NOT NULL DEFAULT FALSE,
395         thaw_date               TIMESTAMP WITH TIME ZONE,
396         shelf_time              TIMESTAMP WITH TIME ZONE,
397     cut_in_line     BOOL,
398         mint_condition  BOOL NOT NULL DEFAULT TRUE,
399         shelf_expire_time TIMESTAMPTZ,
400         current_shelf_lib INT REFERENCES actor.org_unit DEFERRABLE INITIALLY DEFERRED,
401     behind_desk BOOLEAN NOT NULL DEFAULT FALSE
402 );
403 ALTER TABLE action.hold_request ADD CONSTRAINT sms_check CHECK (
404     sms_notify IS NULL
405     OR sms_carrier IS NOT NULL -- and implied sms_notify IS NOT NULL
406 );
407
408
409 CREATE OR REPLACE FUNCTION action.hold_request_clear_map () RETURNS TRIGGER AS $$
410 BEGIN
411   DELETE FROM action.hold_copy_map WHERE hold = NEW.id;
412   RETURN NEW;
413 END;
414 $$ LANGUAGE PLPGSQL;
415
416 CREATE TRIGGER hold_request_clear_map_tgr
417     AFTER UPDATE ON action.hold_request
418     FOR EACH ROW
419     WHEN (
420         (NEW.cancel_time IS NOT NULL AND OLD.cancel_time IS NULL)
421         OR (NEW.fulfillment_time IS NOT NULL AND OLD.fulfillment_time IS NULL)
422     )
423     EXECUTE PROCEDURE action.hold_request_clear_map();
424
425 CREATE INDEX hold_request_target_idx ON action.hold_request (target);
426 CREATE INDEX hold_request_usr_idx ON action.hold_request (usr);
427 CREATE INDEX hold_request_pickup_lib_idx ON action.hold_request (pickup_lib);
428 CREATE INDEX hold_request_current_copy_idx ON action.hold_request (current_copy);
429 CREATE INDEX hold_request_prev_check_time_idx ON action.hold_request (prev_check_time);
430 CREATE INDEX hold_request_fulfillment_staff_idx ON action.hold_request ( fulfillment_staff );
431 CREATE INDEX hold_request_requestor_idx         ON action.hold_request ( requestor );
432 CREATE INDEX hold_request_open_idx ON action.hold_request (id) WHERE cancel_time IS NULL AND fulfillment_time IS NULL;
433 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;
434 CREATE UNIQUE INDEX hold_request_capture_protect_idx ON action.hold_request (current_copy) WHERE current_copy IS NOT NULL AND capture_time IS NOT NULL AND cancel_time IS NULL AND fulfillment_time IS NULL;
435 CREATE INDEX hold_request_copy_capture_time_idx ON action.hold_request (current_copy,capture_time);
436 CREATE INDEX hold_request_open_captured_shelf_lib_idx ON action.hold_request (current_shelf_lib) WHERE capture_time IS NOT NULL AND fulfillment_time IS NULL AND (pickup_lib <> current_shelf_lib);
437 CREATE INDEX hold_fulfillment_time_idx ON action.hold_request (fulfillment_time) WHERE fulfillment_time IS NOT NULL;
438 CREATE INDEX hold_request_time_idx ON action.hold_request (request_time);
439
440
441 CREATE TABLE action.hold_request_note (
442
443     id     BIGSERIAL PRIMARY KEY,
444     hold   BIGINT    NOT NULL REFERENCES action.hold_request (id)
445                               ON DELETE CASCADE
446                               DEFERRABLE INITIALLY DEFERRED,
447     title  TEXT      NOT NULL,
448     body   TEXT      NOT NULL,
449     slip   BOOL      NOT NULL DEFAULT FALSE,
450     pub    BOOL      NOT NULL DEFAULT FALSE,
451     staff  BOOL      NOT NULL DEFAULT FALSE  -- created by staff
452
453 );
454 CREATE INDEX ahrn_hold_idx ON action.hold_request_note (hold);
455
456
457 CREATE TABLE action.hold_notification (
458         id              SERIAL                          PRIMARY KEY,
459         hold            INT                             NOT NULL REFERENCES action.hold_request (id)
460                                                                         ON DELETE CASCADE
461                                                                         DEFERRABLE INITIALLY DEFERRED,
462         notify_staff    INT                     REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
463         notify_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
464         method          TEXT                            NOT NULL, -- email address or phone number
465         note            TEXT
466 );
467 CREATE INDEX ahn_hold_idx ON action.hold_notification (hold);
468 CREATE INDEX ahn_notify_staff_idx ON action.hold_notification ( notify_staff );
469
470 CREATE TABLE action.hold_copy_map (
471         id              BIGSERIAL       PRIMARY KEY,
472         hold            INT     NOT NULL REFERENCES action.hold_request (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
473         target_copy     BIGINT  NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
474         proximity       NUMERIC,
475         CONSTRAINT copy_once_per_hold UNIQUE (hold,target_copy)
476 );
477 -- CREATE INDEX acm_hold_idx ON action.hold_copy_map (hold);
478 CREATE INDEX acm_copy_idx ON action.hold_copy_map (target_copy);
479
480 CREATE OR REPLACE FUNCTION
481     action.hold_request_regen_copy_maps(
482         hold_id INTEGER, copy_ids INTEGER[]) RETURNS VOID AS $$
483     DELETE FROM action.hold_copy_map WHERE hold = $1;
484     INSERT INTO action.hold_copy_map (hold, target_copy) SELECT $1, UNNEST($2);
485 $$ LANGUAGE SQL;
486
487 CREATE TABLE action.transit_copy (
488         id                      SERIAL                          PRIMARY KEY,
489         source_send_time        TIMESTAMP WITH TIME ZONE,
490         dest_recv_time          TIMESTAMP WITH TIME ZONE,
491         target_copy             BIGINT                          NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
492         source                  INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
493         dest                    INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
494         prev_hop                INT                             REFERENCES action.transit_copy (id) DEFERRABLE INITIALLY DEFERRED,
495         copy_status             INT                             NOT NULL REFERENCES config.copy_status (id) DEFERRABLE INITIALLY DEFERRED,
496         persistant_transfer     BOOL                            NOT NULL DEFAULT FALSE,
497         prev_dest       INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED
498 );
499 CREATE INDEX active_transit_dest_idx ON "action".transit_copy (dest); 
500 CREATE INDEX active_transit_source_idx ON "action".transit_copy (source);
501 CREATE INDEX active_transit_cp_idx ON "action".transit_copy (target_copy);
502
503
504 CREATE TABLE action.hold_transit_copy (
505         hold    INT     REFERENCES action.hold_request (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED
506 ) INHERITS (action.transit_copy);
507 ALTER TABLE action.hold_transit_copy ADD PRIMARY KEY (id);
508 -- 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
509 CREATE INDEX active_hold_transit_dest_idx ON "action".hold_transit_copy (dest);
510 CREATE INDEX active_hold_transit_source_idx ON "action".hold_transit_copy (source);
511 CREATE INDEX active_hold_transit_cp_idx ON "action".hold_transit_copy (target_copy);
512 CREATE INDEX hold_transit_copy_hold_idx on action.hold_transit_copy (hold);
513
514
515 CREATE TABLE action.unfulfilled_hold_list (
516         id              BIGSERIAL                       PRIMARY KEY,
517         current_copy    BIGINT                          NOT NULL,
518         hold            INT                             NOT NULL,
519         circ_lib        INT                             NOT NULL,
520         fail_time       TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
521 );
522 CREATE INDEX uhr_hold_idx ON action.unfulfilled_hold_list (hold);
523
524 CREATE OR REPLACE VIEW action.unfulfilled_hold_loops AS
525     SELECT  u.hold,
526             c.circ_lib,
527             count(*)
528       FROM  action.unfulfilled_hold_list u
529             JOIN asset.copy c ON (c.id = u.current_copy)
530       GROUP BY 1,2;
531
532 CREATE OR REPLACE VIEW action.unfulfilled_hold_min_loop AS
533     SELECT  hold,
534             min(count)
535       FROM  action.unfulfilled_hold_loops
536       GROUP BY 1;
537
538 CREATE OR REPLACE VIEW action.unfulfilled_hold_innermost_loop AS
539     SELECT  DISTINCT l.*
540       FROM  action.unfulfilled_hold_loops l
541             JOIN action.unfulfilled_hold_min_loop m USING (hold)
542       WHERE l.count = m.min;
543
544 CREATE VIEW action.unfulfilled_hold_max_loop AS
545     SELECT  hold,
546             max(count) AS max
547       FROM  action.unfulfilled_hold_loops
548       GROUP BY 1;
549
550
551 CREATE TABLE action.aged_hold_request (
552     usr_post_code               TEXT,
553     usr_home_ou         INT     NOT NULL,
554     usr_profile         INT     NOT NULL,
555     usr_birth_year              INT,
556     staff_placed        BOOLEAN NOT NULL,
557     LIKE action.hold_request
558 );
559 ALTER TABLE action.aged_hold_request
560       ADD PRIMARY KEY (id),
561       DROP COLUMN usr,
562       DROP COLUMN requestor,
563       DROP COLUMN sms_carrier,
564       ALTER COLUMN phone_notify TYPE BOOLEAN
565             USING CASE WHEN phone_notify IS NULL OR phone_notify = '' THEN FALSE ELSE TRUE END,
566       ALTER COLUMN sms_notify TYPE BOOLEAN
567             USING CASE WHEN sms_notify IS NULL OR sms_notify = '' THEN FALSE ELSE TRUE END,
568       ALTER COLUMN phone_notify SET NOT NULL,
569       ALTER COLUMN sms_notify SET NOT NULL;
570 CREATE INDEX aged_hold_request_target_idx ON action.aged_hold_request (target);
571 CREATE INDEX aged_hold_request_pickup_lib_idx ON action.aged_hold_request (pickup_lib);
572 CREATE INDEX aged_hold_request_current_copy_idx ON action.aged_hold_request (current_copy);
573 CREATE INDEX aged_hold_request_fulfillment_staff_idx ON action.aged_hold_request ( fulfillment_staff );
574
575 CREATE OR REPLACE VIEW action.all_hold_request AS
576     SELECT DISTINCT
577            COALESCE(a.post_code, b.post_code) AS usr_post_code,
578            p.home_ou AS usr_home_ou,
579            p.profile AS usr_profile,
580            EXTRACT(YEAR FROM p.dob)::INT AS usr_birth_year,
581            CAST(ahr.requestor <> ahr.usr AS BOOLEAN) AS staff_placed,
582            ahr.id,
583            ahr.request_time,
584            ahr.capture_time,
585            ahr.fulfillment_time,
586            ahr.checkin_time,
587            ahr.return_time,
588            ahr.prev_check_time,
589            ahr.expire_time,
590            ahr.cancel_time,
591            ahr.cancel_cause,
592            ahr.cancel_note,
593            ahr.target,
594            ahr.current_copy,
595            ahr.fulfillment_staff,
596            ahr.fulfillment_lib,
597            ahr.request_lib,
598            ahr.selection_ou,
599            ahr.selection_depth,
600            ahr.pickup_lib,
601            ahr.hold_type,
602            ahr.holdable_formats,
603            CASE
604            WHEN ahr.phone_notify IS NULL THEN FALSE
605            WHEN ahr.phone_notify = '' THEN FALSE
606            ELSE TRUE
607            END AS phone_notify,
608            ahr.email_notify,
609            CASE
610            WHEN ahr.sms_notify IS NULL THEN FALSE
611            WHEN ahr.sms_notify = '' THEN FALSE
612            ELSE TRUE
613            END AS sms_notify,
614            ahr.frozen,
615            ahr.thaw_date,
616            ahr.shelf_time,
617            ahr.cut_in_line,
618            ahr.mint_condition,
619            ahr.shelf_expire_time,
620            ahr.current_shelf_lib,
621            ahr.behind_desk
622     FROM action.hold_request ahr
623          JOIN actor.usr p ON (ahr.usr = p.id)
624          LEFT JOIN actor.usr_address a ON (p.mailing_address = a.id)
625          LEFT JOIN actor.usr_address b ON (p.billing_address = b.id)
626     UNION ALL
627     SELECT 
628            usr_post_code,
629            usr_home_ou,
630            usr_profile,
631            usr_birth_year,
632            staff_placed,
633            id,
634            request_time,
635            capture_time,
636            fulfillment_time,
637            checkin_time,
638            return_time,
639            prev_check_time,
640            expire_time,
641            cancel_time,
642            cancel_cause,
643            cancel_note,
644            target,
645            current_copy,
646            fulfillment_staff,
647            fulfillment_lib,
648            request_lib,
649            selection_ou,
650            selection_depth,
651            pickup_lib,
652            hold_type,
653            holdable_formats,
654            phone_notify,
655            email_notify,
656            sms_notify,
657            frozen,
658            thaw_date,
659            shelf_time,
660            cut_in_line,
661            mint_condition,
662            shelf_expire_time,
663            current_shelf_lib,
664            behind_desk
665     FROM action.aged_hold_request;
666
667 CREATE OR REPLACE FUNCTION action.age_hold_on_delete () RETURNS TRIGGER AS $$
668 DECLARE
669 BEGIN
670     -- Archive a copy of the old row to action.aged_hold_request
671
672     INSERT INTO action.aged_hold_request
673            (usr_post_code,
674             usr_home_ou,
675             usr_profile,
676             usr_birth_year,
677             staff_placed,
678             id,
679             request_time,
680             capture_time,
681             fulfillment_time,
682             checkin_time,
683             return_time,
684             prev_check_time,
685             expire_time,
686             cancel_time,
687             cancel_cause,
688             cancel_note,
689             target,
690             current_copy,
691             fulfillment_staff,
692             fulfillment_lib,
693             request_lib,
694             selection_ou,
695             selection_depth,
696             pickup_lib,
697             hold_type,
698             holdable_formats,
699             phone_notify,
700             email_notify,
701             sms_notify,
702             frozen,
703             thaw_date,
704             shelf_time,
705             cut_in_line,
706             mint_condition,
707             shelf_expire_time,
708             current_shelf_lib,
709             behind_desk)
710       SELECT 
711            usr_post_code,
712            usr_home_ou,
713            usr_profile,
714            usr_birth_year,
715            staff_placed,
716            id,
717            request_time,
718            capture_time,
719            fulfillment_time,
720            checkin_time,
721            return_time,
722            prev_check_time,
723            expire_time,
724            cancel_time,
725            cancel_cause,
726            cancel_note,
727            target,
728            current_copy,
729            fulfillment_staff,
730            fulfillment_lib,
731            request_lib,
732            selection_ou,
733            selection_depth,
734            pickup_lib,
735            hold_type,
736            holdable_formats,
737            phone_notify,
738            email_notify,
739            sms_notify,
740            frozen,
741            thaw_date,
742            shelf_time,
743            cut_in_line,
744            mint_condition,
745            shelf_expire_time,
746            current_shelf_lib,
747            behind_desk
748         FROM action.all_hold_request WHERE id = OLD.id;
749
750     RETURN OLD;
751 END;
752 $$ LANGUAGE 'plpgsql';
753
754 CREATE TRIGGER action_hold_request_aging_tgr
755         BEFORE DELETE ON action.hold_request
756         FOR EACH ROW
757         EXECUTE PROCEDURE action.age_hold_on_delete ();
758
759 CREATE TABLE action.fieldset (
760     id              SERIAL          PRIMARY KEY,
761     owner           INT             NOT NULL REFERENCES actor.usr (id)
762                                     DEFERRABLE INITIALLY DEFERRED,
763         owning_lib      INT             NOT NULL REFERENCES actor.org_unit (id)
764                                     DEFERRABLE INITIALLY DEFERRED,
765         status          TEXT            NOT NULL
766                                         CONSTRAINT valid_status CHECK ( status in
767                                                                         ( 'PENDING', 'APPLIED', 'ERROR' )),
768     creation_time   TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
769     scheduled_time  TIMESTAMPTZ,
770     applied_time    TIMESTAMPTZ,
771     classname       TEXT            NOT NULL, -- an IDL class name
772     name            TEXT            NOT NULL,
773     stored_query    INT             REFERENCES query.stored_query (id)
774                                     DEFERRABLE INITIALLY DEFERRED,
775     pkey_value      TEXT,
776         CONSTRAINT lib_name_unique UNIQUE (owning_lib, name),
777     CONSTRAINT fieldset_one_or_the_other CHECK (
778         (stored_query IS NOT NULL AND pkey_value IS NULL) OR
779         (pkey_value IS NOT NULL AND stored_query IS NULL)
780     )
781         -- the CHECK constraint means we can update the fields for a single
782         -- row without all the extra overhead involved in a query
783 );
784
785 CREATE INDEX action_fieldset_sched_time_idx ON action.fieldset( scheduled_time );
786 CREATE INDEX action_owner_idx               ON action.fieldset( owner );
787
788
789 CREATE TABLE action.fieldset_col_val (
790     id              SERIAL  PRIMARY KEY,
791     fieldset        INT     NOT NULL REFERENCES action.fieldset
792                                          ON DELETE CASCADE
793                                          DEFERRABLE INITIALLY DEFERRED,
794     col             TEXT    NOT NULL,  -- "field" from the idl ... the column on the table
795     val             TEXT,              -- value for the column ... NULL means, well, NULL
796     CONSTRAINT fieldset_col_once_per_set UNIQUE (fieldset, col)
797 );
798
799
800 -- represents a circ chain summary
801 CREATE TYPE action.circ_chain_summary AS (
802     num_circs INTEGER,
803     start_time TIMESTAMP WITH TIME ZONE,
804     checkout_workstation TEXT,
805     last_renewal_time TIMESTAMP WITH TIME ZONE, -- NULL if no renewals
806     last_stop_fines TEXT,
807     last_stop_fines_time TIMESTAMP WITH TIME ZONE,
808     last_renewal_workstation TEXT, -- NULL if no renewals
809     last_checkin_workstation TEXT,
810     last_checkin_time TIMESTAMP WITH TIME ZONE,
811     last_checkin_scan_time TIMESTAMP WITH TIME ZONE
812 );
813
814
815 CREATE OR REPLACE FUNCTION action.circ_chain ( ctx_circ_id BIGINT ) RETURNS SETOF action.circulation AS $$
816 DECLARE
817     tmp_circ action.circulation%ROWTYPE;
818     circ_0 action.circulation%ROWTYPE;
819 BEGIN
820
821     SELECT INTO tmp_circ * FROM action.circulation WHERE id = ctx_circ_id;
822
823     IF tmp_circ IS NULL THEN
824         RETURN NEXT tmp_circ;
825     END IF;
826     circ_0 := tmp_circ;
827
828     -- find the front of the chain
829     WHILE TRUE LOOP
830         SELECT INTO tmp_circ * FROM action.circulation WHERE id = tmp_circ.parent_circ;
831         IF tmp_circ IS NULL THEN
832             EXIT;
833         END IF;
834         circ_0 := tmp_circ;
835     END LOOP;
836
837     -- now send the circs to the caller, oldest to newest
838     tmp_circ := circ_0;
839     WHILE TRUE LOOP
840         IF tmp_circ IS NULL THEN
841             EXIT;
842         END IF;
843         RETURN NEXT tmp_circ;
844         SELECT INTO tmp_circ * FROM action.circulation WHERE parent_circ = tmp_circ.id;
845     END LOOP;
846
847 END;
848 $$ LANGUAGE 'plpgsql';
849
850 CREATE OR REPLACE FUNCTION action.summarize_circ_chain ( ctx_circ_id BIGINT ) RETURNS action.circ_chain_summary AS $$
851
852 DECLARE
853
854     -- first circ in the chain
855     circ_0 action.circulation%ROWTYPE;
856
857     -- last circ in the chain
858     circ_n action.circulation%ROWTYPE;
859
860     -- circ chain under construction
861     chain action.circ_chain_summary;
862     tmp_circ action.circulation%ROWTYPE;
863
864 BEGIN
865     
866     chain.num_circs := 0;
867     FOR tmp_circ IN SELECT * FROM action.circ_chain(ctx_circ_id) LOOP
868
869         IF chain.num_circs = 0 THEN
870             circ_0 := tmp_circ;
871         END IF;
872
873         chain.num_circs := chain.num_circs + 1;
874         circ_n := tmp_circ;
875     END LOOP;
876
877     chain.start_time := circ_0.xact_start;
878     chain.last_stop_fines := circ_n.stop_fines;
879     chain.last_stop_fines_time := circ_n.stop_fines_time;
880     chain.last_checkin_time := circ_n.checkin_time;
881     chain.last_checkin_scan_time := circ_n.checkin_scan_time;
882     SELECT INTO chain.checkout_workstation name FROM actor.workstation WHERE id = circ_0.workstation;
883     SELECT INTO chain.last_checkin_workstation name FROM actor.workstation WHERE id = circ_n.checkin_workstation;
884
885     IF chain.num_circs > 1 THEN
886         chain.last_renewal_time := circ_n.xact_start;
887         SELECT INTO chain.last_renewal_workstation name FROM actor.workstation WHERE id = circ_n.workstation;
888     END IF;
889
890     RETURN chain;
891
892 END;
893 $$ LANGUAGE 'plpgsql';
894
895 -- same as action.circ_chain, but returns action.all_circulation 
896 -- rows which may include aged circulations.
897 CREATE OR REPLACE FUNCTION action.all_circ_chain (ctx_circ_id INTEGER) 
898     RETURNS SETOF action.all_circulation AS $$
899 DECLARE
900     tmp_circ action.all_circulation%ROWTYPE;
901     circ_0 action.all_circulation%ROWTYPE;
902 BEGIN
903
904     SELECT INTO tmp_circ * FROM action.all_circulation WHERE id = ctx_circ_id;
905
906     IF tmp_circ IS NULL THEN
907         RETURN NEXT tmp_circ;
908     END IF;
909     circ_0 := tmp_circ;
910
911     -- find the front of the chain
912     WHILE TRUE LOOP
913         SELECT INTO tmp_circ * FROM action.all_circulation 
914             WHERE id = tmp_circ.parent_circ;
915         IF tmp_circ IS NULL THEN
916             EXIT;
917         END IF;
918         circ_0 := tmp_circ;
919     END LOOP;
920
921     -- now send the circs to the caller, oldest to newest
922     tmp_circ := circ_0;
923     WHILE TRUE LOOP
924         IF tmp_circ IS NULL THEN
925             EXIT;
926         END IF;
927         RETURN NEXT tmp_circ;
928         SELECT INTO tmp_circ * FROM action.all_circulation 
929             WHERE parent_circ = tmp_circ.id;
930     END LOOP;
931
932 END;
933 $$ LANGUAGE 'plpgsql';
934
935 -- same as action.summarize_circ_chain, but returns data collected
936 -- from action.all_circulation, which may include aged circulations.
937 CREATE OR REPLACE FUNCTION action.summarize_all_circ_chain 
938     (ctx_circ_id INTEGER) RETURNS action.circ_chain_summary AS $$
939
940 DECLARE
941
942     -- first circ in the chain
943     circ_0 action.all_circulation%ROWTYPE;
944
945     -- last circ in the chain
946     circ_n action.all_circulation%ROWTYPE;
947
948     -- circ chain under construction
949     chain action.circ_chain_summary;
950     tmp_circ action.all_circulation%ROWTYPE;
951
952 BEGIN
953     
954     chain.num_circs := 0;
955     FOR tmp_circ IN SELECT * FROM action.all_circ_chain(ctx_circ_id) LOOP
956
957         IF chain.num_circs = 0 THEN
958             circ_0 := tmp_circ;
959         END IF;
960
961         chain.num_circs := chain.num_circs + 1;
962         circ_n := tmp_circ;
963     END LOOP;
964
965     chain.start_time := circ_0.xact_start;
966     chain.last_stop_fines := circ_n.stop_fines;
967     chain.last_stop_fines_time := circ_n.stop_fines_time;
968     chain.last_checkin_time := circ_n.checkin_time;
969     chain.last_checkin_scan_time := circ_n.checkin_scan_time;
970     SELECT INTO chain.checkout_workstation name FROM actor.workstation WHERE id = circ_0.workstation;
971     SELECT INTO chain.last_checkin_workstation name FROM actor.workstation WHERE id = circ_n.checkin_workstation;
972
973     IF chain.num_circs > 1 THEN
974         chain.last_renewal_time := circ_n.xact_start;
975         SELECT INTO chain.last_renewal_workstation name FROM actor.workstation WHERE id = circ_n.workstation;
976     END IF;
977
978     RETURN chain;
979
980 END;
981 $$ LANGUAGE 'plpgsql';
982
983
984 CREATE OR REPLACE FUNCTION action.usr_visible_holds (usr_id INT) RETURNS SETOF action.hold_request AS $func$
985 DECLARE
986     h               action.hold_request%ROWTYPE;
987     view_age        INTERVAL;
988     view_count      INT;
989     usr_view_count  actor.usr_setting%ROWTYPE;
990     usr_view_age    actor.usr_setting%ROWTYPE;
991     usr_view_start  actor.usr_setting%ROWTYPE;
992 BEGIN
993     SELECT * INTO usr_view_count FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_count';
994     SELECT * INTO usr_view_age FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_age';
995     SELECT * INTO usr_view_start FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_start';
996
997     FOR h IN
998         SELECT  *
999           FROM  action.hold_request
1000           WHERE usr = usr_id
1001                 AND fulfillment_time IS NULL
1002                 AND cancel_time IS NULL
1003           ORDER BY request_time DESC
1004     LOOP
1005         RETURN NEXT h;
1006     END LOOP;
1007
1008     IF usr_view_start.value IS NULL THEN
1009         RETURN;
1010     END IF;
1011
1012     IF usr_view_age.value IS NOT NULL THEN
1013         -- User opted in and supplied a retention age
1014         IF oils_json_to_text(usr_view_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ) THEN
1015             view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
1016         ELSE
1017             view_age := oils_json_to_text(usr_view_age.value)::INTERVAL;
1018         END IF;
1019     ELSE
1020         -- User opted in
1021         view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
1022     END IF;
1023
1024     IF usr_view_count.value IS NOT NULL THEN
1025         view_count := oils_json_to_text(usr_view_count.value)::INT;
1026     ELSE
1027         view_count := 1000;
1028     END IF;
1029
1030     -- show some fulfilled/canceled holds
1031     FOR h IN
1032         SELECT  *
1033           FROM  action.hold_request
1034           WHERE usr = usr_id
1035                 AND ( fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL )
1036                 AND COALESCE(fulfillment_time, cancel_time) > NOW() - view_age
1037           ORDER BY COALESCE(fulfillment_time, cancel_time) DESC
1038           LIMIT view_count
1039     LOOP
1040         RETURN NEXT h;
1041     END LOOP;
1042
1043     RETURN;
1044 END;
1045 $func$ LANGUAGE PLPGSQL;
1046
1047 CREATE OR REPLACE FUNCTION action.purge_circulations () RETURNS INT AS $func$
1048 DECLARE
1049     org_keep_age    INTERVAL;
1050     org_use_last    BOOL = false;
1051     org_age_is_min  BOOL = false;
1052     org_keep_count  INT;
1053
1054     keep_age        INTERVAL;
1055
1056     target_acp      RECORD;
1057     circ_chain_head action.circulation%ROWTYPE;
1058     circ_chain_tail action.circulation%ROWTYPE;
1059
1060     count_purged    INT;
1061     num_incomplete  INT;
1062
1063     last_finished   TIMESTAMP WITH TIME ZONE;
1064 BEGIN
1065
1066     count_purged := 0;
1067
1068     SELECT value::INTERVAL INTO org_keep_age FROM config.global_flag WHERE name = 'history.circ.retention_age' AND enabled;
1069
1070     SELECT value::INT INTO org_keep_count FROM config.global_flag WHERE name = 'history.circ.retention_count' AND enabled;
1071     IF org_keep_count IS NULL THEN
1072         RETURN count_purged; -- Gimme a count to keep, or I keep them all, forever
1073     END IF;
1074
1075     SELECT enabled INTO org_use_last FROM config.global_flag WHERE name = 'history.circ.retention_uses_last_finished';
1076     SELECT enabled INTO org_age_is_min FROM config.global_flag WHERE name = 'history.circ.retention_age_is_min';
1077
1078     -- First, find copies with more than keep_count non-renewal circs
1079     FOR target_acp IN
1080         SELECT  target_copy,
1081                 COUNT(*) AS total_real_circs
1082           FROM  action.circulation
1083           WHERE parent_circ IS NULL
1084                 AND xact_finish IS NOT NULL
1085           GROUP BY target_copy
1086           HAVING COUNT(*) > org_keep_count
1087     LOOP
1088         -- And, for those, select circs that are finished and older than keep_age
1089         FOR circ_chain_head IN
1090             -- For reference, the subquery uses a window function to order the circs newest to oldest and number them
1091             -- The outer query then uses that information to skip the most recent set the library wants to keep
1092             -- End result is we don't care what order they come out in, as they are all potentials for deletion.
1093             SELECT ac.* FROM action.circulation ac JOIN (
1094               SELECT  rank() OVER (ORDER BY xact_start DESC), ac.id
1095                 FROM  action.circulation ac
1096                 WHERE ac.target_copy = target_acp.target_copy
1097                   AND ac.parent_circ IS NULL
1098                 ORDER BY ac.xact_start ) ranked USING (id)
1099                 WHERE ranked.rank > org_keep_count
1100         LOOP
1101
1102             SELECT * INTO circ_chain_tail FROM action.circ_chain(circ_chain_head.id) ORDER BY xact_start DESC LIMIT 1;
1103             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);
1104             CONTINUE WHEN circ_chain_tail.xact_finish IS NULL OR num_incomplete > 0;
1105
1106             IF NOT org_use_last THEN
1107                 last_finished := circ_chain_tail.xact_finish;
1108             END IF;
1109
1110             keep_age := COALESCE( org_keep_age, '2000 years'::INTERVAL );
1111
1112             IF org_age_is_min THEN
1113                 keep_age := GREATEST( keep_age, org_keep_age );
1114             END IF;
1115
1116             CONTINUE WHEN AGE(NOW(), last_finished) < keep_age;
1117
1118             -- We've passed the purging tests, purge the circ chain starting at the end
1119             -- A trigger should auto-purge the rest of the chain.
1120             DELETE FROM action.circulation WHERE id = circ_chain_tail.id;
1121
1122             count_purged := count_purged + 1;
1123
1124         END LOOP;
1125     END LOOP;
1126
1127     return count_purged;
1128 END;
1129 $func$ LANGUAGE PLPGSQL;
1130
1131 CREATE OR REPLACE FUNCTION action.purge_holds() RETURNS INT AS $func$
1132 DECLARE
1133   current_hold RECORD;
1134   purged_holds INT;
1135   cgf_d INTERVAL;
1136   cgf_f INTERVAL;
1137   cgf_c INTERVAL;
1138   prev_usr INT;
1139   user_start TIMESTAMPTZ;
1140   user_age INTERVAL;
1141   user_count INT;
1142 BEGIN
1143   purged_holds := 0;
1144   SELECT INTO cgf_d value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age' AND enabled;
1145   SELECT INTO cgf_f value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age_fulfilled' AND enabled;
1146   SELECT INTO cgf_c value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age_canceled' AND enabled;
1147   FOR current_hold IN
1148     SELECT
1149       rank() OVER (PARTITION BY usr ORDER BY COALESCE(fulfillment_time, cancel_time) DESC),
1150       cgf_cs.value::INTERVAL as cgf_cs,
1151       ahr.*
1152     FROM
1153       action.hold_request ahr
1154       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)
1155     WHERE
1156       (fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL)
1157   LOOP
1158     IF prev_usr IS NULL OR prev_usr != current_hold.usr THEN
1159       prev_usr := current_hold.usr;
1160       SELECT INTO user_start oils_json_to_text(value)::TIMESTAMPTZ FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_start';
1161       SELECT INTO user_age oils_json_to_text(value)::INTERVAL FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_age';
1162       SELECT INTO user_count oils_json_to_text(value)::INT FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_count';
1163       IF user_start IS NOT NULL THEN
1164         user_age := LEAST(user_age, AGE(NOW(), user_start));
1165       END IF;
1166       IF user_count IS NULL THEN
1167         user_count := 1000; -- Assumption based on the user visible holds routine
1168       END IF;
1169     END IF;
1170     -- Library keep age trumps user keep anything, for purposes of being able to hold on to things when staff canceled and such.
1171     IF current_hold.fulfillment_time IS NOT NULL AND current_hold.fulfillment_time > NOW() - COALESCE(cgf_f, cgf_d) THEN
1172       CONTINUE;
1173     END IF;
1174     IF current_hold.cancel_time IS NOT NULL AND current_hold.cancel_time > NOW() - COALESCE(current_hold.cgf_cs, cgf_c, cgf_d) THEN
1175       CONTINUE;
1176     END IF;
1177
1178     -- User keep age needs combining with count. If too old AND within the count, keep!
1179     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
1180       CONTINUE;
1181     END IF;
1182
1183     -- All checks should have passed, delete!
1184     DELETE FROM action.hold_request WHERE id = current_hold.id;
1185     purged_holds := purged_holds + 1;
1186   END LOOP;
1187   RETURN purged_holds;
1188 END;
1189 $func$ LANGUAGE plpgsql;
1190
1191 CREATE OR REPLACE FUNCTION action.apply_fieldset(
1192         fieldset_id IN INT,        -- id from action.fieldset
1193         table_name  IN TEXT,       -- table to be updated
1194         pkey_name   IN TEXT,       -- name of primary key column in that table
1195         query       IN TEXT        -- query constructed by qstore (for query-based
1196                                    --    fieldsets only; otherwise null
1197 )
1198 RETURNS TEXT AS $$
1199 DECLARE
1200         statement TEXT;
1201         fs_status TEXT;
1202         fs_pkey_value TEXT;
1203         fs_query TEXT;
1204         sep CHAR;
1205         status_code TEXT;
1206         msg TEXT;
1207         update_count INT;
1208         cv RECORD;
1209 BEGIN
1210         -- Sanity checks
1211         IF fieldset_id IS NULL THEN
1212                 RETURN 'Fieldset ID parameter is NULL';
1213         END IF;
1214         IF table_name IS NULL THEN
1215                 RETURN 'Table name parameter is NULL';
1216         END IF;
1217         IF pkey_name IS NULL THEN
1218                 RETURN 'Primary key name parameter is NULL';
1219         END IF;
1220         --
1221         statement := 'UPDATE ' || table_name || ' SET';
1222         --
1223         SELECT
1224                 status,
1225                 quote_literal( pkey_value )
1226         INTO
1227                 fs_status,
1228                 fs_pkey_value
1229         FROM
1230                 action.fieldset
1231         WHERE
1232                 id = fieldset_id;
1233         --
1234         IF fs_status IS NULL THEN
1235                 RETURN 'No fieldset found for id = ' || fieldset_id;
1236         ELSIF fs_status = 'APPLIED' THEN
1237                 RETURN 'Fieldset ' || fieldset_id || ' has already been applied';
1238         END IF;
1239         --
1240         sep := '';
1241         FOR cv IN
1242                 SELECT  col,
1243                                 val
1244                 FROM    action.fieldset_col_val
1245                 WHERE   fieldset = fieldset_id
1246         LOOP
1247                 statement := statement || sep || ' ' || cv.col
1248                                          || ' = ' || coalesce( quote_literal( cv.val ), 'NULL' );
1249                 sep := ',';
1250         END LOOP;
1251         --
1252         IF sep = '' THEN
1253                 RETURN 'Fieldset ' || fieldset_id || ' has no column values defined';
1254         END IF;
1255         --
1256         -- Add the WHERE clause.  This differs according to whether it's a
1257         -- single-row fieldset or a query-based fieldset.
1258         --
1259         IF query IS NULL        AND fs_pkey_value IS NULL THEN
1260                 RETURN 'Incomplete fieldset: neither a primary key nor a query available';
1261         ELSIF query IS NOT NULL AND fs_pkey_value IS NULL THEN
1262             fs_query := rtrim( query, ';' );
1263             statement := statement || ' WHERE ' || pkey_name || ' IN ( '
1264                          || fs_query || ' );';
1265         ELSIF query IS NULL     AND fs_pkey_value IS NOT NULL THEN
1266                 statement := statement || ' WHERE ' || pkey_name || ' = '
1267                                      || fs_pkey_value || ';';
1268         ELSE  -- both are not null
1269                 RETURN 'Ambiguous fieldset: both a primary key and a query provided';
1270         END IF;
1271         --
1272         -- Execute the update
1273         --
1274         BEGIN
1275                 EXECUTE statement;
1276                 GET DIAGNOSTICS update_count = ROW_COUNT;
1277                 --
1278                 IF UPDATE_COUNT > 0 THEN
1279                         status_code := 'APPLIED';
1280                         msg := NULL;
1281                 ELSE
1282                         status_code := 'ERROR';
1283                         msg := 'No eligible rows found for fieldset ' || fieldset_id;
1284         END IF;
1285         EXCEPTION WHEN OTHERS THEN
1286                 status_code := 'ERROR';
1287                 msg := 'Unable to apply fieldset ' || fieldset_id
1288                            || ': ' || sqlerrm;
1289         END;
1290         --
1291         -- Update fieldset status
1292         --
1293         UPDATE action.fieldset
1294         SET status       = status_code,
1295             applied_time = now()
1296         WHERE id = fieldset_id;
1297         --
1298         RETURN msg;
1299 END;
1300 $$ LANGUAGE plpgsql;
1301
1302 COMMENT ON FUNCTION action.apply_fieldset( INT, TEXT, TEXT, TEXT ) IS $$
1303 Applies a specified fieldset, using a supplied table name and primary
1304 key name.  The query parameter should be non-null only for
1305 query-based fieldsets.
1306
1307 Returns NULL if successful, or an error message if not.
1308 $$;
1309
1310 CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity(
1311     ahr_id INT,
1312     acp_id BIGINT,
1313     copy_context_ou INT DEFAULT NULL
1314     -- TODO maybe? hold_context_ou INT DEFAULT NULL.  This would optionally
1315     -- support an "ahprox" measurement: adjust prox between copy circ lib and
1316     -- hold request lib, but I'm unsure whether to use this theoretical
1317     -- argument only in the baseline calculation or later in the other
1318     -- queries in this function.
1319 ) RETURNS NUMERIC AS $f$
1320 DECLARE
1321     aoupa           actor.org_unit_proximity_adjustment%ROWTYPE;
1322     ahr             action.hold_request%ROWTYPE;
1323     acp             asset.copy%ROWTYPE;
1324     acn             asset.call_number%ROWTYPE;
1325     acl             asset.copy_location%ROWTYPE;
1326     baseline_prox   NUMERIC;
1327
1328     icl_list        INT[];
1329     iol_list        INT[];
1330     isl_list        INT[];
1331     hpl_list        INT[];
1332     hrl_list        INT[];
1333
1334 BEGIN
1335
1336     SELECT * INTO ahr FROM action.hold_request WHERE id = ahr_id;
1337     SELECT * INTO acp FROM asset.copy WHERE id = acp_id;
1338     SELECT * INTO acn FROM asset.call_number WHERE id = acp.call_number;
1339     SELECT * INTO acl FROM asset.copy_location WHERE id = acp.location;
1340
1341     IF copy_context_ou IS NULL THEN
1342         copy_context_ou := acp.circ_lib;
1343     END IF;
1344
1345     -- First, gather the baseline proximity of "here" to pickup lib
1346     SELECT prox INTO baseline_prox FROM actor.org_unit_proximity WHERE from_org = copy_context_ou AND to_org = ahr.pickup_lib;
1347
1348     -- Find any absolute adjustments, and set the baseline prox to that
1349     SELECT  adj.* INTO aoupa
1350       FROM  actor.org_unit_proximity_adjustment adj
1351             LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
1352             LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
1353             LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acl_ol.id = adj.copy_location)
1354             LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
1355             LEFT JOIN actor.org_unit_ancestors_distance(ahr.request_lib) ahr_rl ON (ahr_rl.id = adj.hold_request_lib)
1356       WHERE (adj.circ_mod IS NULL OR adj.circ_mod = acp.circ_modifier) AND
1357             (adj.item_circ_lib IS NULL OR adj.item_circ_lib = acp_cl.id) AND
1358             (adj.item_owning_lib IS NULL OR adj.item_owning_lib = acn_ol.id) AND
1359             (adj.copy_location IS NULL OR adj.copy_location = acl_ol.id) AND
1360             (adj.hold_pickup_lib IS NULL OR adj.hold_pickup_lib = ahr_pl.id) AND
1361             (adj.hold_request_lib IS NULL OR adj.hold_request_lib = ahr_rl.id) AND
1362             absolute_adjustment AND
1363             COALESCE(acp_cl.id, acn_ol.id, acl_ol.id, ahr_pl.id, ahr_rl.id) IS NOT NULL
1364       ORDER BY
1365             COALESCE(acp_cl.distance,999)
1366                 + COALESCE(acn_ol.distance,999)
1367                 + COALESCE(acl_ol.distance,999)
1368                 + COALESCE(ahr_pl.distance,999)
1369                 + COALESCE(ahr_rl.distance,999),
1370             adj.pos
1371       LIMIT 1;
1372
1373     IF FOUND THEN
1374         baseline_prox := aoupa.prox_adjustment;
1375     END IF;
1376
1377     -- Now find any relative adjustments, and change the baseline prox based on them
1378     FOR aoupa IN
1379         SELECT  adj.* 
1380           FROM  actor.org_unit_proximity_adjustment adj
1381                 LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
1382                 LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
1383                 LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
1384                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
1385                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.request_lib) ahr_rl ON (ahr_rl.id = adj.hold_request_lib)
1386           WHERE (adj.circ_mod IS NULL OR adj.circ_mod = acp.circ_modifier) AND
1387                 (adj.item_circ_lib IS NULL OR adj.item_circ_lib = acp_cl.id) AND
1388                 (adj.item_owning_lib IS NULL OR adj.item_owning_lib = acn_ol.id) AND
1389                 (adj.copy_location IS NULL OR adj.copy_location = acl_ol.id) AND
1390                 (adj.hold_pickup_lib IS NULL OR adj.hold_pickup_lib = ahr_pl.id) AND
1391                 (adj.hold_request_lib IS NULL OR adj.hold_request_lib = ahr_rl.id) AND
1392                 NOT absolute_adjustment AND
1393                 COALESCE(acp_cl.id, acn_ol.id, acl_ol.id, ahr_pl.id, ahr_rl.id) IS NOT NULL
1394     LOOP
1395         baseline_prox := baseline_prox + aoupa.prox_adjustment;
1396     END LOOP;
1397
1398     RETURN baseline_prox;
1399 END;
1400 $f$ LANGUAGE PLPGSQL;
1401
1402 CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity_update () RETURNS TRIGGER AS $f$
1403 BEGIN
1404     NEW.proximity := action.hold_copy_calculated_proximity(NEW.hold,NEW.target_copy);
1405     RETURN NEW;
1406 END;
1407 $f$ LANGUAGE PLPGSQL;
1408
1409 CREATE TRIGGER hold_copy_proximity_update_tgr BEFORE INSERT OR UPDATE ON action.hold_copy_map FOR EACH ROW EXECUTE PROCEDURE action.hold_copy_calculated_proximity_update ();
1410
1411 CREATE TABLE action.usr_circ_history (
1412     id           BIGSERIAL PRIMARY KEY,
1413     usr          INTEGER NOT NULL REFERENCES actor.usr(id)
1414                  DEFERRABLE INITIALLY DEFERRED,
1415     xact_start   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
1416     target_copy  BIGINT NOT NULL, -- asset.copy.id / serial.unit.id
1417     due_date     TIMESTAMP WITH TIME ZONE NOT NULL,
1418     checkin_time TIMESTAMP WITH TIME ZONE,
1419     source_circ  BIGINT REFERENCES action.circulation(id)
1420                  ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED
1421 );
1422
1423 CREATE INDEX action_usr_circ_history_usr_idx ON action.usr_circ_history ( usr );
1424
1425 CREATE TRIGGER action_usr_circ_history_target_copy_trig 
1426     AFTER INSERT OR UPDATE ON action.usr_circ_history 
1427     FOR EACH ROW EXECUTE PROCEDURE evergreen.fake_fkey_tgr('target_copy');
1428
1429 CREATE OR REPLACE FUNCTION action.maintain_usr_circ_history() 
1430     RETURNS TRIGGER AS $FUNK$
1431 DECLARE
1432     cur_circ  BIGINT;
1433     first_circ BIGINT;
1434 BEGIN                                                                          
1435
1436     -- Any retention value signifies history is enabled.
1437     -- This assumes that clearing these values via external 
1438     -- process deletes the action.usr_circ_history rows.
1439     -- TODO: replace these settings w/ a single bool setting?
1440     PERFORM 1 FROM actor.usr_setting 
1441         WHERE usr = NEW.usr AND value IS NOT NULL AND name IN (
1442             'history.circ.retention_age', 
1443             'history.circ.retention_start'
1444         );
1445
1446     IF NOT FOUND THEN
1447         RETURN NEW;
1448     END IF;
1449
1450     IF TG_OP = 'INSERT' AND NEW.parent_circ IS NULL THEN
1451         -- Starting a new circulation.  Insert the history row.
1452         INSERT INTO action.usr_circ_history 
1453             (usr, xact_start, target_copy, due_date, source_circ)
1454         VALUES (
1455             NEW.usr, 
1456             NEW.xact_start, 
1457             NEW.target_copy, 
1458             NEW.due_date, 
1459             NEW.id
1460         );
1461
1462         RETURN NEW;
1463     END IF;
1464
1465     -- find the first and last circs in the circ chain 
1466     -- for the currently modified circ.
1467     FOR cur_circ IN SELECT id FROM action.circ_chain(NEW.id) LOOP
1468         IF first_circ IS NULL THEN
1469             first_circ := cur_circ;
1470             CONTINUE;
1471         END IF;
1472         -- Allow the loop to continue so that at as the loop
1473         -- completes cur_circ points to the final circulation.
1474     END LOOP;
1475
1476     IF NEW.id <> cur_circ THEN
1477         -- Modifying an intermediate circ.  Ignore it.
1478         RETURN NEW;
1479     END IF;
1480
1481     -- Update the due_date/checkin_time on the history row if the current 
1482     -- circ is the last circ in the chain and an update is warranted.
1483
1484     UPDATE action.usr_circ_history 
1485         SET 
1486             due_date = NEW.due_date,
1487             checkin_time = NEW.checkin_time
1488         WHERE 
1489             source_circ = first_circ 
1490             AND (
1491                 due_date <> NEW.due_date OR (
1492                     (checkin_time IS NULL AND NEW.checkin_time IS NOT NULL) OR
1493                     (checkin_time IS NOT NULL AND NEW.checkin_time IS NULL) OR
1494                     (checkin_time <> NEW.checkin_time)
1495                 )
1496             );
1497     RETURN NEW;
1498 END;                                                                           
1499 $FUNK$ LANGUAGE PLPGSQL; 
1500
1501 CREATE TRIGGER maintain_usr_circ_history_tgr 
1502     AFTER INSERT OR UPDATE ON action.circulation 
1503     FOR EACH ROW EXECUTE PROCEDURE action.maintain_usr_circ_history();
1504
1505 COMMIT;