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