Patch from Thomas Berezansky to implement circulation matchpoint value fallthrough...
authormiker <miker@dcc99617-32d9-48b4-a31d-7c20da2025e4>
Wed, 16 Feb 2011 20:47:11 +0000 (20:47 +0000)
committermiker <miker@dcc99617-32d9-48b4-a31d-7c20da2025e4>
Wed, 16 Feb 2011 20:47:11 +0000 (20:47 +0000)
Background, taken from https://bugs.launchpad.net/evergreen/+bug/635463:

Theory:
Make the things returned (circulate, duration_rule, recurring_fine_rule, max_fine_rule, hard_due_date, total_copy_hold_ratio, available_copy_hold_ratio and renewals) "fall through" when set to NULL.

The circ matrix edit screen has had some changes too, partially for showing "Inherited" in some places where appropriate, partially for field ordering and editing display and consistency. The fields that show up earlier contribute to tests that will be applied to the circulation attempt, while the "results" that do not change what rows are considered are grouped at the end.

Pros:

You can override a subset of those fields with a specific rule while allowing broader rules to fill in the holes.
This may result in less duplication of information across rules, making things easier to maintain.
Thus, this may result in less rules in general, and thus less processing time on sorting them overall.

Cons:

Manually figuring out the specifics of what will happen will take more time/effort.
Changing a single rule may have a greater unintended effect on other rules.
Staff would need training for when to have a rule fall through and when to set it specifically.
More time to return from the DB for any rule that is "falling through" to broader rules.

Examples for the following org tree:

CONS
-SYSA
--LIBC
--LIBD
-SYSB
--LIBE
--LIBF

Implementing the following "business" rules:

At the CONS level:
By default, everything circulates, uses DFLT_DUR duration, DFLT_RFINE recurring fine, and DFLT_MFINE max fine.
Circ Modifier "book" uses the duration BOOK_DUR
Reference flagged materials don't circulate

At the SYSA level there are no special rules.

At the SYSB level the max fine should be SYSB_MFINE.

At the LIBC level the recurring fine is LIBC_RFINE

At the LIBD level circ modifier "book" uses the DFLT_DUR duration instead of "BOOK_DUR"

At the LIBE level reference flagged materials circulate.

At the LIBF level there are no special rules.

The current method would require the following circ rules to implement those business rules:

CIRC_LIB CIRC_MOD REFERENCE CIRC? DURATION_RULE RECURRING_FINE MAX_FINE
CONS NULL NULL TRUE DFLT_DUR DFLT_RFINE DFLT_MFINE
CONS NULL TRUE FALSE DFLT_DUR DFLT_RFINE DFLT_MFINE
CONS book NULL TRUE BOOK_DUR DFLT_RFINE DFLT_MFINE
CONS book TRUE FALSE BOOK_DUR DFLT_RFINE DFLT_MFINE
SYSB NULL NULL TRUE DFLT_DUR DFLT_RFINE SYSB_MFINE
SYSB NULL TRUE FALSE DFLT_DUR DFLT_RFINE SYSB_MFINE
SYSB book NULL TRUE BOOK_DUR DFLT_RFINE SYSB_MFINE
SYSB book TRUE FALSE BOOK_DUR DFLT_RFINE SYSB_MFINE
LIBC NULL NULL TRUE DFLT_DUR LIBC_RFINE DFLT_MFINE
LIBC NULL TRUE FALSE DFLT_DUR LIBC_RFINE DFLT_MFINE
LIBC book NULL TRUE BOOK_DUR LIBC_RFINE DFLT_MFINE
LIBC book TRUE FALSE BOOK_DUR LIBC_RFINE DFLT_MFINE
LIBD book NULL TRUE DFLT_DUR DFLT_RFINE DFLT_MFINE
LIBD book TRUE FALSE DFLT_DUR DFLT_RFINE DFLT_MFINE
LIBE NULL NULL TRUE DFLT_DUR DFLT_RFINE SYSB_MFINE
LIBE book NULL TRUE BOOK_DUR DFLT_RFINE SYSB_MFINE

16 circ rules total.

The new method would require the following circ rules to implement those business rules:

CIRC_LIB CIRC_MOD REFERENCE CIRC? DURATION_RULE RECURRING_FINE MAX_FINE
CONS NULL NULL TRUE DFLT_DUR DFLT_RFINE DFLT_MFINE
CONS book NULL NULL BOOK_DUR NULL NULL
CONS NULL TRUE FALSE NULL NULL NULL
SYSB NULL NULL NULL NULL NULL SYSB_MFINE
LIBC NULL NULL NULL NULL LIBC_RFINE NULL
LIBD book NULL NULL DFLT_DUR NULL NULL
LIBE NULL TRUE TRUE NULL NULL NULL

7 circ rules total.

Starting with the above, lets assume that SYSA wants to change their recurring fine to SYSA_RFINE.
LIBC's recurring fine is to be unchanged.

The current method requires the following changes:

ADD the following entries:
CIRC_LIB CIRC_MOD REFERENCE CIRC? DURATION_RULE RECURRING_FINE MAX_FINE
SYSA NULL NULL TRUE DFLT_DUR SYSA_RFINE DFLT_MFINE
SYSA NULL TRUE FALSE DFLT_DUR SYSA_RFINE DFLT_MFINE
SYSA book NULL TRUE BOOK_DUR SYSA_RFINE DFLT_MFINE
SYSA book TRUE FALSE BOOK_DUR SYSA_RFINE DFLT_MFINE

UPDATE the LIBD entries:
CIRC_LIB CIRC_MOD REFERENCE CIRC? DURATION_RULE RECURRING_FINE MAX_FINE
LIBD book NULL TRUE DFLT_DUR SYSA_RFINE DFLT_MFINE
LIBD book TRUE FALSE DFLT_DUR SYSA_RFINE DFLT_MFINE

4 rules added, 2 changed, total is now 20 rules.

The new method would require the following changes:

ADD the following entry:
CIRC_LIB CIRC_MOD REFERENCE CIRC? DURATION_RULE RECURRING_FINE MAX_FINE
SYSA NULL NULL NULL NULL SYSA_RFINE NULL

1 rule added, 0 changed, total is now 8 rules.

git-svn-id: svn://svn.open-ils.org/ILS/trunk@19453 dcc99617-32d9-48b4-a31d-7c20da2025e4

13 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/100.circ_matrix.sql
Open-ILS/src/sql/Pg/110.hold_matrix.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/0487.circ_matrix_fallthrough.sql [new file with mode: 0644]
Open-ILS/web/js/dojo/openils/widget/AutoFieldWidget.js
Open-ILS/web/js/dojo/openils/widget/AutoGrid.js
Open-ILS/web/js/dojo/openils/widget/EditPane.js
Open-ILS/web/js/dojo/openils/widget/nls/AutoFieldWidget.js
Open-ILS/web/js/ui/default/conify/global/config/circ_matrix_matchpoint.js
Open-ILS/web/templates/default/conify/global/config/circ_matrix_matchpoint.tt2

index 0f13006..60e38b8 100644 (file)
@@ -1146,13 +1146,15 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <field reporter:label="MARC Form" name="marc_form" oils_persist:primitive="string" reporter:datatype="link"/>
                        <field reporter:label="Videorecording Format" name="marc_vr_format" oils_persist:primitive="string" reporter:datatype="link"/>
                        <field reporter:label="Reference?" name="ref_flag" reporter:datatype="bool"/>
+            <field reporter:label="Juvenile?" name="juvenile_flag" reporter:datatype="bool"/>
                        <field reporter:label="User Age: Lower Bound" name="usr_age_lower_bound" reporter:datatype="text"/>
                        <field reporter:label="User Age: Upper Bound" name="usr_age_upper_bound" reporter:datatype="text"/>
                        <field reporter:label="Circulate?" name="circulate" reporter:datatype="bool"/>
-                       <field reporter:label="Duration Rule" name="duration_rule" reporter:datatype="link" oils_obj:required="true"/>
-                       <field reporter:label="Recurring Fine Rule" name="recurring_fine_rule" reporter:datatype="link" oils_obj:required="true"/>
-                       <field reporter:label="Max Fine Rule" name="max_fine_rule" reporter:datatype="link" oils_obj:required="true"/>
+                       <field reporter:label="Duration Rule" name="duration_rule" reporter:datatype="link"/>
+                       <field reporter:label="Recurring Fine Rule" name="recurring_fine_rule" reporter:datatype="link"/>
+                       <field reporter:label="Max Fine Rule" name="max_fine_rule" reporter:datatype="link"/>
             <field reporter:label="Hard Due Date" name="hard_due_date" reporter:datatype="link"/>
+            <field reporter:label="Renewals Override" name="renewals" reporter:datatype="int"/>
                        <field reporter:label="Script Test" name="script_test" reporter:datatype="text"/>
                        <field name="total_copy_hold_ratio" reporter:datatype="float" reporter:label="Minimum Total Copy/Hold Ratio"/>
                        <field name="available_copy_hold_ratio" reporter:datatype="float" reporter:label="Minimum Available Copy/Hold Ratio"/>
index cc30638..97e4771 100644 (file)
@@ -1151,16 +1151,15 @@ sub run_indb_circ_test {
     $self->circ_test_success($U->is_true($results->[0]->{success}));
 
     if(my $mp = $results->[0]->{matchpoint}) {
-        $logger->info("circulator: circ policy test found matchpoint $mp");
-        $self->circ_matrix_matchpoint(
-            $self->editor->retrieve_config_circ_matrix_matchpoint([
-                $mp,
-                {   flesh => 1,
-                    flesh_fields => {ccmm => 
-                        ['duration_rule', 'recurring_fine_rule', 'max_fine_rule', 'hard_due_date']}
-                }
-            ])
-        );
+        $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
+        $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
+        $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
+        if($results->[0]->{renewals}) {
+            $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
+        }
+        $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
+        $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
+        $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
     }
 
     return $self->matrix_test_result($results);
index 857d1a1..839cf8b 100644 (file)
@@ -70,7 +70,7 @@ CREATE TABLE config.upgrade_log (
     install_date    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
 );
 
-INSERT INTO config.upgrade_log (version) VALUES ('0486'); -- berick
+INSERT INTO config.upgrade_log (version) VALUES ('0487'); -- miker (for tsbere)
 
 CREATE TABLE config.bib_source (
        id              SERIAL  PRIMARY KEY,
index 0ed8e67..e0f3b3d 100644 (file)
@@ -95,11 +95,12 @@ CREATE TABLE config.circ_matrix_matchpoint (
     usr_age_lower_bound  INTERVAL,
     usr_age_upper_bound  INTERVAL,
     -- "Result" Fields
-    circulate            BOOL    NOT NULL DEFAULT TRUE,    -- Hard "can't circ" flag requiring an override
-    duration_rule        INT     NOT NULL REFERENCES config.rule_circ_duration (id) DEFERRABLE INITIALLY DEFERRED,
-    recurring_fine_rule  INT     NOT NULL REFERENCES config.rule_recurring_fine (id) DEFERRABLE INITIALLY DEFERRED,
-    max_fine_rule        INT     NOT NULL REFERENCES config.rule_max_fine (id) DEFERRABLE INITIALLY DEFERRED,
+    circulate            BOOL,   -- Hard "can't circ" flag requiring an override
+    duration_rule        INT     REFERENCES config.rule_circ_duration (id) DEFERRABLE INITIALLY DEFERRED,
+    recurring_fine_rule  INT     REFERENCES config.rule_recurring_fine (id) DEFERRABLE INITIALLY DEFERRED,
+    max_fine_rule        INT     REFERENCES config.rule_max_fine (id) DEFERRABLE INITIALLY DEFERRED,
     hard_due_date        INT     REFERENCES config.hard_due_date (id) DEFERRABLE INITIALLY DEFERRED,
+    renewals             INT,    -- Renewal count override
     script_test          TEXT,                           -- javascript source 
     total_copy_hold_ratio     FLOAT,
     available_copy_hold_ratio FLOAT
@@ -122,19 +123,24 @@ CREATE TABLE config.circ_matrix_circ_mod_test_map (
     CONSTRAINT cm_once_per_test UNIQUE (circ_mod_test, circ_mod)
 );
 
-CREATE OR REPLACE FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS config.circ_matrix_matchpoint AS $func$
+CREATE TYPE action.found_circ_matrix_matchpoint AS ( success BOOL, matchpoint config.circ_matrix_matchpoint, buildrows INT[] );
+
+CREATE OR REPLACE FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, item_object asset.copy, user_object actor.usr, renewal BOOL ) RETURNS action.found_circ_matrix_matchpoint AS $func$
 DECLARE
-    user_object     actor.usr%ROWTYPE;
-    item_object     asset.copy%ROWTYPE;
     cn_object       asset.call_number%ROWTYPE;
     rec_descriptor  metabib.rec_descriptor%ROWTYPE;
+    cur_matchpoint  config.circ_matrix_matchpoint%ROWTYPE;
     matchpoint      config.circ_matrix_matchpoint%ROWTYPE;
     weights         config.circ_matrix_weights%ROWTYPE;
     user_age        INTERVAL;
     denominator     NUMERIC(6,2);
+    row_list        INT[];
+    result          action.found_circ_matrix_matchpoint;
 BEGIN
-    SELECT INTO user_object     * FROM actor.usr                WHERE id = match_user;
-    SELECT INTO item_object     * FROM asset.copy               WHERE id = match_item;
+    -- Assume failure
+    result.success = false;
+
+    -- Fetch useful data
     SELECT INTO cn_object       * FROM asset.call_number        WHERE id = item_object.call_number;
     SELECT INTO rec_descriptor  * FROM metabib.rec_descriptor   WHERE record = cn_object.record;
 
@@ -181,60 +187,126 @@ BEGIN
        )
     SELECT INTO denominator MAX(distance) + 1 FROM all_distance;
 
-    -- Select the winning matchpoint into the matchpoint variable for returning
-    SELECT INTO matchpoint m.*
-      FROM  config.circ_matrix_matchpoint m
-            /*LEFT*/ JOIN permission.grp_ancestors_distance( user_object.profile ) upgad ON m.grp = upgad.id
-            /*LEFT*/ JOIN actor.org_unit_ancestors_distance( context_ou ) ctoua ON m.org_unit = ctoua.id
-            LEFT JOIN actor.org_unit_ancestors_distance( cn_object.owning_lib ) cnoua ON m.copy_owning_lib = cnoua.id
-            LEFT JOIN actor.org_unit_ancestors_distance( item_object.circ_lib ) iooua ON m.copy_circ_lib = iooua.id
-            LEFT JOIN actor.org_unit_ancestors_distance( user_object.home_ou  ) uhoua ON m.user_home_ou = uhoua.id
-      WHERE m.active
-            -- Permission Groups
-         -- AND (m.grp                      IS NULL OR upgad.id IS NOT NULL) -- Optional Permission Group?
-            -- Org Units
-         -- AND (m.org_unit                 IS NULL OR ctoua.id IS NOT NULL) -- Optional Org Unit?
-            AND (m.copy_owning_lib          IS NULL OR cnoua.id IS NOT NULL)
-            AND (m.copy_circ_lib            IS NULL OR iooua.id IS NOT NULL)
-            AND (m.user_home_ou             IS NULL OR uhoua.id IS NOT NULL)
-            -- Circ Type
-            AND (m.is_renewal               IS NULL OR m.is_renewal = renewal)
-            -- Static User Checks
-            AND (m.juvenile_flag            IS NULL OR m.juvenile_flag = user_object.juvenile)
-            AND (m.usr_age_lower_bound      IS NULL OR (user_age IS NOT NULL AND m.usr_age_lower_bound < user_age))
-            AND (m.usr_age_upper_bound      IS NULL OR (user_age IS NOT NULL AND m.usr_age_upper_bound > user_age))
-            -- Static Item Checks
-            AND (m.circ_modifier            IS NULL OR m.circ_modifier = item_object.circ_modifier)
-            AND (m.marc_type                IS NULL OR m.marc_type = COALESCE(item_object.circ_as_type, rec_descriptor.item_type))
-            AND (m.marc_form                IS NULL OR m.marc_form = rec_descriptor.item_form)
-            AND (m.marc_vr_format           IS NULL OR m.marc_vr_format = rec_descriptor.vr_format)
-            AND (m.ref_flag                 IS NULL OR m.ref_flag = item_object.ref)
-      ORDER BY
-            -- Permission Groups
-            CASE WHEN upgad.distance        IS NOT NULL THEN 2^(2*weights.grp - (upgad.distance/denominator)) ELSE 0.0 END +
-            -- Org Units
-            CASE WHEN ctoua.distance        IS NOT NULL THEN 2^(2*weights.org_unit - (ctoua.distance/denominator)) ELSE 0.0 END +
-            CASE WHEN cnoua.distance        IS NOT NULL THEN 2^(2*weights.copy_owning_lib - (cnoua.distance/denominator)) ELSE 0.0 END +
-            CASE WHEN iooua.distance        IS NOT NULL THEN 2^(2*weights.copy_circ_lib - (iooua.distance/denominator)) ELSE 0.0 END +
-            CASE WHEN uhoua.distance        IS NOT NULL THEN 2^(2*weights.user_home_ou - (uhoua.distance/denominator)) ELSE 0.0 END +
-            -- Circ Type                    -- Note: 4^x is equiv to 2^(2*x)
-            CASE WHEN m.is_renewal          IS NOT NULL THEN 4^weights.is_renewal ELSE 0.0 END +
-            -- Static User Checks
-            CASE WHEN m.juvenile_flag       IS NOT NULL THEN 4^weights.juvenile_flag ELSE 0.0 END +
-            CASE WHEN m.usr_age_lower_bound IS NOT NULL THEN 4^weights.usr_age_lower_bound ELSE 0.0 END +
-            CASE WHEN m.usr_age_upper_bound IS NOT NULL THEN 4^weights.usr_age_upper_bound ELSE 0.0 END +
-            -- Static Item Checks
-            CASE WHEN m.circ_modifier       IS NOT NULL THEN 4^weights.circ_modifier ELSE 0.0 END +
-            CASE WHEN m.marc_type           IS NOT NULL THEN 4^weights.marc_type ELSE 0.0 END +
-            CASE WHEN m.marc_form           IS NOT NULL THEN 4^weights.marc_form ELSE 0.0 END +
-            CASE WHEN m.marc_vr_format      IS NOT NULL THEN 4^weights.marc_vr_format ELSE 0.0 END +
-            CASE WHEN m.ref_flag            IS NOT NULL THEN 4^weights.ref_flag ELSE 0.0 END DESC,
-            -- Final sort on id, so that if two rules have the same sorting in the previous sort they have a defined order
-            -- This prevents "we changed the table order by updating a rule, and we started getting different results"
-            m.id;
-
-    -- Return the entire matchpoint
-    RETURN matchpoint;
+    -- Loop over all the potential matchpoints
+    FOR cur_matchpoint IN
+        SELECT m.*
+          FROM  config.circ_matrix_matchpoint m
+                /*LEFT*/ JOIN permission.grp_ancestors_distance( user_object.profile ) upgad ON m.grp = upgad.id
+                /*LEFT*/ JOIN actor.org_unit_ancestors_distance( context_ou ) ctoua ON m.org_unit = ctoua.id
+                LEFT JOIN actor.org_unit_ancestors_distance( cn_object.owning_lib ) cnoua ON m.copy_owning_lib = cnoua.id
+                LEFT JOIN actor.org_unit_ancestors_distance( item_object.circ_lib ) iooua ON m.copy_circ_lib = iooua.id
+                LEFT JOIN actor.org_unit_ancestors_distance( user_object.home_ou  ) uhoua ON m.user_home_ou = uhoua.id
+          WHERE m.active
+                -- Permission Groups
+             -- AND (m.grp                      IS NULL OR upgad.id IS NOT NULL) -- Optional Permission Group?
+                -- Org Units
+             -- AND (m.org_unit                 IS NULL OR ctoua.id IS NOT NULL) -- Optional Org Unit?
+                AND (m.copy_owning_lib          IS NULL OR cnoua.id IS NOT NULL)
+                AND (m.copy_circ_lib            IS NULL OR iooua.id IS NOT NULL)
+                AND (m.user_home_ou             IS NULL OR uhoua.id IS NOT NULL)
+                -- Circ Type
+                AND (m.is_renewal               IS NULL OR m.is_renewal = renewal)
+                -- Static User Checks
+                AND (m.juvenile_flag            IS NULL OR m.juvenile_flag = user_object.juvenile)
+                AND (m.usr_age_lower_bound      IS NULL OR (user_age IS NOT NULL AND m.usr_age_lower_bound < user_age))
+                AND (m.usr_age_upper_bound      IS NULL OR (user_age IS NOT NULL AND m.usr_age_upper_bound > user_age))
+                -- Static Item Checks
+                AND (m.circ_modifier            IS NULL OR m.circ_modifier = item_object.circ_modifier)
+                AND (m.marc_type                IS NULL OR m.marc_type = COALESCE(item_object.circ_as_type, rec_descriptor.item_type))
+                AND (m.marc_form                IS NULL OR m.marc_form = rec_descriptor.item_form)
+                AND (m.marc_vr_format           IS NULL OR m.marc_vr_format = rec_descriptor.vr_format)
+                AND (m.ref_flag                 IS NULL OR m.ref_flag = item_object.ref)
+          ORDER BY
+                -- Permission Groups
+                CASE WHEN upgad.distance        IS NOT NULL THEN 2^(2*weights.grp - (upgad.distance/denominator)) ELSE 0.0 END +
+                -- Org Units
+                CASE WHEN ctoua.distance        IS NOT NULL THEN 2^(2*weights.org_unit - (ctoua.distance/denominator)) ELSE 0.0 END +
+                CASE WHEN cnoua.distance        IS NOT NULL THEN 2^(2*weights.copy_owning_lib - (cnoua.distance/denominator)) ELSE 0.0 END +
+                CASE WHEN iooua.distance        IS NOT NULL THEN 2^(2*weights.copy_circ_lib - (iooua.distance/denominator)) ELSE 0.0 END +
+                CASE WHEN uhoua.distance        IS NOT NULL THEN 2^(2*weights.user_home_ou - (uhoua.distance/denominator)) ELSE 0.0 END +
+                -- Circ Type                    -- Note: 4^x is equiv to 2^(2*x)
+                CASE WHEN m.is_renewal          IS NOT NULL THEN 4^weights.is_renewal ELSE 0.0 END +
+                -- Static User Checks
+                CASE WHEN m.juvenile_flag       IS NOT NULL THEN 4^weights.juvenile_flag ELSE 0.0 END +
+                CASE WHEN m.usr_age_lower_bound IS NOT NULL THEN 4^weights.usr_age_lower_bound ELSE 0.0 END +
+                CASE WHEN m.usr_age_upper_bound IS NOT NULL THEN 4^weights.usr_age_upper_bound ELSE 0.0 END +
+                -- Static Item Checks
+                CASE WHEN m.circ_modifier       IS NOT NULL THEN 4^weights.circ_modifier ELSE 0.0 END +
+                CASE WHEN m.marc_type           IS NOT NULL THEN 4^weights.marc_type ELSE 0.0 END +
+                CASE WHEN m.marc_form           IS NOT NULL THEN 4^weights.marc_form ELSE 0.0 END +
+                CASE WHEN m.marc_vr_format      IS NOT NULL THEN 4^weights.marc_vr_format ELSE 0.0 END +
+                CASE WHEN m.ref_flag            IS NOT NULL THEN 4^weights.ref_flag ELSE 0.0 END DESC,
+                -- Final sort on id, so that if two rules have the same sorting in the previous sort they have a defined order
+                -- This prevents "we changed the table order by updating a rule, and we started getting different results"
+                m.id LOOP
+
+        -- Record the full matching row list
+        row_list := row_list || cur_matchpoint.id;
+
+        -- No matchpoint yet?
+        IF matchpoint.id IS NULL THEN
+            -- Take the entire matchpoint as a starting point
+            matchpoint := cur_matchpoint;
+            CONTINUE; -- No need to look at this row any more.
+        END IF;
+
+        -- Incomplete matchpoint?
+        IF matchpoint.circulate IS NULL THEN
+            matchpoint.circulate := cur_matchpoint.circulate;
+        END IF;
+        IF matchpoint.duration_rule IS NULL THEN
+            matchpoint.duration_rule := cur_matchpoint.duration_rule;
+        END IF;
+        IF matchpoint.recurring_fine_rule IS NULL THEN
+            matchpoint.recurring_fine_rule := cur_matchpoint.recurring_fine_rule;
+        END IF;
+        IF matchpoint.max_fine_rule IS NULL THEN
+            matchpoint.max_fine_rule := cur_matchpoint.max_fine_rule;
+        END IF;
+        IF matchpoint.hard_due_date IS NULL THEN
+            matchpoint.hard_due_date := cur_matchpoint.hard_due_date;
+        END IF;
+        IF matchpoint.total_copy_hold_ratio IS NULL THEN
+            matchpoint.total_copy_hold_ratio := cur_matchpoint.total_copy_hold_ratio;
+        END IF;
+        IF matchpoint.available_copy_hold_ratio IS NULL THEN
+            matchpoint.available_copy_hold_ratio := cur_matchpoint.available_copy_hold_ratio;
+        END IF;
+        IF matchpoint.renewals IS NULL THEN
+            matchpoint.renewals := cur_matchpoint.renewals;
+        END IF;
+    END LOOP;
+
+    -- Check required fields
+    IF matchpoint.circulate             IS NOT NULL AND
+       matchpoint.duration_rule         IS NOT NULL AND
+       matchpoint.recurring_fine_rule   IS NOT NULL AND
+       matchpoint.max_fine_rule         IS NOT NULL THEN
+        -- All there? We have a completed match.
+        result.success := true;
+    END IF;
+
+    -- Include the assembled matchpoint, even if it isn't complete
+    result.matchpoint := matchpoint;
+
+    -- Include (for debugging) the full list of matching rows
+    result.buildrows := row_list;
+
+    -- Hand the result back to caller
+    RETURN result;
+END;
+$func$ LANGUAGE plpgsql;
+
+-- Helper function - For manual calling, it can be easier to pass in IDs instead of objects
+CREATE OR REPLACE FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.found_circ_matrix_matchpoint AS $func$
+DECLARE
+    item_object asset.copy%ROWTYPE;
+    user_object actor.usr%ROWTYPE;
+BEGIN
+    SELECT INTO item_object * FROM asset.copy  WHERE id = match_item;
+    SELECT INTO user_object * FROM actor.usr   WHERE id = match_user;
+
+    RETURN QUERY SELECT * FROM action.find_circ_matrix_matchpoint( context_ou, item_object, user_object, renewal );
 END;
 $func$ LANGUAGE plpgsql;
 
@@ -291,25 +363,26 @@ BEGIN
 END;
 $func$ LANGUAGE PLPGSQL;
 
-CREATE TYPE action.matrix_test_result AS ( success BOOL, matchpoint INT, fail_part TEXT );
-CREATE OR REPLACE FUNCTION action.item_user_circ_test( circ_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.matrix_test_result AS $func$
+CREATE TYPE action.circ_matrix_test_result AS ( success BOOL, fail_part TEXT, buildrows INT[], matchpoint INT, circulate BOOL, duration_rule INT, recurring_fine_rule INT, max_fine_rule INT, hard_due_date INT, renewals INT );
+CREATE OR REPLACE FUNCTION action.item_user_circ_test( circ_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.circ_matrix_test_result AS $func$
 DECLARE
-    user_object        actor.usr%ROWTYPE;
-    standing_penalty    config.standing_penalty%ROWTYPE;
-    item_object        asset.copy%ROWTYPE;
-    item_status_object    config.copy_status%ROWTYPE;
+    user_object             actor.usr%ROWTYPE;
+    standing_penalty        config.standing_penalty%ROWTYPE;
+    item_object             asset.copy%ROWTYPE;
+    item_status_object      config.copy_status%ROWTYPE;
     item_location_object    asset.copy_location%ROWTYPE;
-    result            action.matrix_test_result;
-    circ_test        config.circ_matrix_matchpoint%ROWTYPE;
-    out_by_circ_mod        config.circ_matrix_circ_mod_test%ROWTYPE;
-    circ_mod_map        config.circ_matrix_circ_mod_test_map%ROWTYPE;
-    hold_ratio          action.hold_stats%ROWTYPE;
-    penalty_type         TEXT;
-    tmp_grp         INT;
-    items_out        INT;
+    result                  action.circ_matrix_test_result;
+    circ_test               action.found_circ_matrix_matchpoint;
+    circ_matchpoint         config.circ_matrix_matchpoint%ROWTYPE;
+    out_by_circ_mod         config.circ_matrix_circ_mod_test%ROWTYPE;
+    circ_mod_map            config.circ_matrix_circ_mod_test_map%ROWTYPE;
+    hold_ratio              action.hold_stats%ROWTYPE;
+    penalty_type            TEXT;
+    items_out               INT;
     context_org_list        INT[];
-    done            BOOL := FALSE;
+    done                    BOOL := FALSE;
 BEGIN
+    -- Assume success unless we hit a failure condition
     result.success := TRUE;
 
     -- Fail if the user is BARRED
@@ -335,17 +408,6 @@ BEGIN
         RETURN;
     END IF;
 
-    SELECT INTO circ_test * FROM action.find_circ_matrix_matchpoint(circ_ou, match_item, match_user, renewal);
-    result.matchpoint := circ_test.id;
-
-    -- Fail if we couldn't find a matchpoint
-    IF result.matchpoint IS NULL THEN
-        result.fail_part := 'no_matchpoint';
-        result.success := FALSE;
-        done := TRUE;
-        RETURN NEXT result;
-    END IF;
-
     IF user_object.barred IS TRUE THEN
         result.fail_part := 'actor.usr.barred';
         result.success := FALSE;
@@ -383,37 +445,29 @@ BEGIN
         RETURN NEXT result;
     END IF;
 
-    SELECT INTO context_org_list ARRAY_ACCUM(id) FROM actor.org_unit_full_path( circ_test.org_unit );
+    SELECT INTO circ_test * FROM action.find_circ_matrix_matchpoint(circ_ou, item_object, user_object, renewal);
 
-    -- Fail if the test is set to hard non-circulating
-    IF circ_test.circulate IS FALSE THEN
-        result.fail_part := 'config.circ_matrix_test.circulate';
+    circ_matchpoint             := circ_test.matchpoint;
+    result.matchpoint           := circ_matchpoint.id;
+    result.circulate            := circ_matchpoint.circulate;
+    result.duration_rule        := circ_matchpoint.duration_rule;
+    result.recurring_fine_rule  := circ_matchpoint.recurring_fine_rule;
+    result.max_fine_rule        := circ_matchpoint.max_fine_rule;
+    result.hard_due_date        := circ_matchpoint.hard_due_date;
+    result.renewals             := circ_matchpoint.renewals;
+    result.buildrows            := circ_test.buildrows;
+
+    -- Fail if we couldn't find a matchpoint
+    IF circ_test.success = false THEN
+        result.fail_part := 'no_matchpoint';
         result.success := FALSE;
         done := TRUE;
         RETURN NEXT result;
+        RETURN; -- All tests after this point require a matchpoint. No sense in running on an incomplete or missing one.
     END IF;
 
-    -- Fail if the total copy-hold ratio is too low
-    IF circ_test.total_copy_hold_ratio IS NOT NULL THEN
-        SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
-        IF hold_ratio.total_copy_ratio IS NOT NULL AND hold_ratio.total_copy_ratio < circ_test.total_copy_hold_ratio THEN
-            result.fail_part := 'config.circ_matrix_test.total_copy_hold_ratio';
-            result.success := FALSE;
-            done := TRUE;
-            RETURN NEXT result;
-        END IF;
-    END IF;
-
-    -- Fail if the available copy-hold ratio is too low
-    IF circ_test.available_copy_hold_ratio IS NOT NULL THEN
-        SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
-        IF hold_ratio.available_copy_ratio IS NOT NULL AND hold_ratio.available_copy_ratio < circ_test.available_copy_hold_ratio THEN
-            result.fail_part := 'config.circ_matrix_test.available_copy_hold_ratio';
-            result.success := FALSE;
-            done := TRUE;
-            RETURN NEXT result;
-        END IF;
-    END IF;
+    -- Apparently....use the circ matchpoint org unit to determine what org units are valid.
+    SELECT INTO context_org_list ARRAY_ACCUM(id) FROM actor.org_unit_full_path( circ_matchpoint.org_unit );
 
     IF renewal THEN
         penalty_type = '%RENEW%';
@@ -436,8 +490,40 @@ BEGIN
         RETURN NEXT result;
     END LOOP;
 
+    -- Fail if the test is set to hard non-circulating
+    IF circ_matchpoint.circulate IS FALSE THEN
+        result.fail_part := 'config.circ_matrix_test.circulate';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the total copy-hold ratio is too low
+    IF circ_matchpoint.total_copy_hold_ratio IS NOT NULL THEN
+        SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
+        IF hold_ratio.total_copy_ratio IS NOT NULL AND hold_ratio.total_copy_ratio < circ_matchpoint.total_copy_hold_ratio THEN
+            result.fail_part := 'config.circ_matrix_test.total_copy_hold_ratio';
+            result.success := FALSE;
+            done := TRUE;
+            RETURN NEXT result;
+        END IF;
+    END IF;
+
+    -- Fail if the available copy-hold ratio is too low
+    IF circ_matchpoint.available_copy_hold_ratio IS NOT NULL THEN
+        IF hold_ratio.hold_count IS NULL THEN
+            SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
+        END IF;
+        IF hold_ratio.available_copy_ratio IS NOT NULL AND hold_ratio.available_copy_ratio < circ_matchpoint.available_copy_hold_ratio THEN
+            result.fail_part := 'config.circ_matrix_test.available_copy_hold_ratio';
+            result.success := FALSE;
+            done := TRUE;
+            RETURN NEXT result;
+        END IF;
+    END IF;
+
     -- Fail if the user has too many items with specific circ_modifiers checked out
-    FOR out_by_circ_mod IN SELECT * FROM config.circ_matrix_circ_mod_test WHERE matchpoint = circ_test.id LOOP
+    FOR out_by_circ_mod IN SELECT * FROM config.circ_matrix_circ_mod_test WHERE matchpoint = circ_matchpoint.id LOOP
         SELECT  INTO items_out COUNT(*)
           FROM  action.circulation circ
             JOIN asset.copy cp ON (cp.id = circ.target_copy)
@@ -463,11 +549,11 @@ BEGIN
 END;
 $func$ LANGUAGE plpgsql;
 
-CREATE OR REPLACE FUNCTION action.item_user_circ_test( INT, BIGINT, INT ) RETURNS SETOF action.matrix_test_result AS $func$
+CREATE OR REPLACE FUNCTION action.item_user_circ_test( INT, BIGINT, INT ) RETURNS SETOF action.circ_matrix_test_result AS $func$
     SELECT * FROM action.item_user_circ_test( $1, $2, $3, FALSE );
 $func$ LANGUAGE SQL;
 
-CREATE OR REPLACE FUNCTION action.item_user_renew_test( INT, BIGINT, INT ) RETURNS SETOF action.matrix_test_result AS $func$
+CREATE OR REPLACE FUNCTION action.item_user_renew_test( INT, BIGINT, INT ) RETURNS SETOF action.circ_matrix_test_result AS $func$
     SELECT * FROM action.item_user_circ_test( $1, $2, $3, TRUE );
 $func$ LANGUAGE SQL;
 
index 1821b1d..a025979 100644 (file)
@@ -200,6 +200,7 @@ BEGIN
 END;
 $func$ LANGUAGE 'plpgsql';
 
+CREATE TYPE action.matrix_test_result AS ( success BOOL, matchpoint INT, fail_part TEXT );
 CREATE OR REPLACE FUNCTION action.hold_request_permit_test( pickup_ou INT, request_ou INT, match_item BIGINT, match_user INT, match_requestor INT, retargetting BOOL ) RETURNS SETOF action.matrix_test_result AS $func$
 DECLARE
     matchpoint_id        INT;
index 2e2ebbb..409656b 100644 (file)
@@ -2174,7 +2174,7 @@ SELECT SETVAL('asset.copy_location_id_seq'::TEXT, 100);
 INSERT INTO asset.call_number VALUES (-1,1,NOW(),1,NOW(),-1,1,'UNCATALOGED');
 
 -- circ matrix
-INSERT INTO config.circ_matrix_matchpoint (org_unit,grp,duration_rule,recurring_fine_rule,max_fine_rule) VALUES (1,1,11,1,1);
+INSERT INTO config.circ_matrix_matchpoint (org_unit,grp,circulate,duration_rule,recurring_fine_rule,max_fine_rule) VALUES (1,1,true,11,1,1);
 
 INSERT INTO config.circ_matrix_weights(name, org_unit, grp, circ_modifier, marc_type, marc_form, marc_vr_format, copy_circ_lib, copy_owning_lib, user_home_ou, ref_flag, juvenile_flag, is_renewal, usr_age_upper_bound, usr_age_lower_bound) VALUES 
     ('Default', 10.0, 11.0, 5.0, 4.0, 3.0, 2.0, 8.0, 8.0, 8.0, 1.0, 6.0, 7.0, 0.0, 0.0),
diff --git a/Open-ILS/src/sql/Pg/upgrade/0487.circ_matrix_fallthrough.sql b/Open-ILS/src/sql/Pg/upgrade/0487.circ_matrix_fallthrough.sql
new file mode 100644 (file)
index 0000000..0c4b71e
--- /dev/null
@@ -0,0 +1,404 @@
+BEGIN;
+
+INSERT INTO config.upgrade_log (version) VALUES ('0487'); -- tsbere via miker
+
+-- Circ matchpoint table changes
+
+ALTER TABLE config.circ_matrix_matchpoint
+    ALTER COLUMN circulate DROP NOT NULL, -- Fallthrough enable
+    ALTER COLUMN circulate DROP DEFAULT, -- Stop defaulting to true to enable default to fallthrough
+    ALTER COLUMN duration_rule DROP NOT NULL, -- Fallthrough enable
+    ALTER COLUMN recurring_fine_rule DROP NOT NULL, -- Fallthrough enable
+    ALTER COLUMN max_fine_rule DROP NOT NULL, -- Fallthrough enable
+    ADD COLUMN renewals INT; -- Renewals override
+
+-- Changing return types requires explicit dropping of old versions
+DROP FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, match_item BIGINT, match_user INT, renewal BOOL );
+DROP FUNCTION action.item_user_circ_test( circ_ou INT, match_item BIGINT, match_user INT, renewal BOOL );
+DROP FUNCTION action.item_user_circ_test( INT, BIGINT, INT );
+DROP FUNCTION action.item_user_renew_test( INT, BIGINT, INT );
+
+-- New return types
+CREATE TYPE action.found_circ_matrix_matchpoint AS ( success BOOL, matchpoint config.circ_matrix_matchpoint, buildrows INT[] );
+CREATE TYPE action.circ_matrix_test_result AS ( success BOOL, fail_part TEXT, buildrows INT[], matchpoint INT, circulate BOOL, duration_rule INT, recurring_fine_rule INT, max_fine_rule INT, hard_due_date INT, renewals INT );
+
+-- Replacement functions
+CREATE OR REPLACE FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, item_object asset.copy, user_object actor.usr, renewal BOOL ) RETURNS action.found_circ_matrix_matchpoint AS $func$
+DECLARE
+    cn_object       asset.call_number%ROWTYPE;
+    rec_descriptor  metabib.rec_descriptor%ROWTYPE;
+    cur_matchpoint  config.circ_matrix_matchpoint%ROWTYPE;
+    matchpoint      config.circ_matrix_matchpoint%ROWTYPE;
+    weights         config.circ_matrix_weights%ROWTYPE;
+    user_age        INTERVAL;
+    denominator     NUMERIC(6,2);
+    row_list        INT[];
+    result          action.found_circ_matrix_matchpoint;
+BEGIN
+    -- Assume failure
+    result.success = false;
+
+    -- Fetch useful data
+    SELECT INTO cn_object       * FROM asset.call_number        WHERE id = item_object.call_number;
+    SELECT INTO rec_descriptor  * FROM metabib.rec_descriptor   WHERE record = cn_object.record;
+
+    -- Pre-generate this so we only calc it once
+    IF user_object.dob IS NOT NULL THEN
+        SELECT INTO user_age age(user_object.dob);
+    END IF;
+
+    -- Grab the closest set circ weight setting.
+    SELECT INTO weights cw.*
+      FROM config.weight_assoc wa
+           JOIN config.circ_matrix_weights cw ON (cw.id = wa.circ_weights)
+           JOIN actor.org_unit_ancestors_distance( context_ou ) d ON (wa.org_unit = d.id)
+      WHERE active
+      ORDER BY d.distance
+      LIMIT 1;
+
+    -- No weights? Bad admin! Defaults to handle that anyway.
+    IF weights.id IS NULL THEN
+        weights.grp                 := 11.0;
+        weights.org_unit            := 10.0;
+        weights.circ_modifier       := 5.0;
+        weights.marc_type           := 4.0;
+        weights.marc_form           := 3.0;
+        weights.marc_vr_format      := 2.0;
+        weights.copy_circ_lib       := 8.0;
+        weights.copy_owning_lib     := 8.0;
+        weights.user_home_ou        := 8.0;
+        weights.ref_flag            := 1.0;
+        weights.juvenile_flag       := 6.0;
+        weights.is_renewal          := 7.0;
+        weights.usr_age_lower_bound := 0.0;
+        weights.usr_age_upper_bound := 0.0;
+    END IF;
+
+    -- Determine the max (expected) depth (+1) of the org tree and max depth of the permisson tree
+    -- If you break your org tree with funky parenting this may be wrong
+    -- Note: This CTE is duplicated in the find_hold_matrix_matchpoint function, and it may be a good idea to split it off to a function
+    -- We use one denominator for all tree-based checks for when permission groups and org units have the same weighting
+    WITH all_distance(distance) AS (
+            SELECT depth AS distance FROM actor.org_unit_type
+        UNION
+                   SELECT distance AS distance FROM permission.grp_ancestors_distance((SELECT id FROM permission.grp_tree WHERE parent IS NULL))
+       )
+    SELECT INTO denominator MAX(distance) + 1 FROM all_distance;
+
+    -- Loop over all the potential matchpoints
+    FOR cur_matchpoint IN
+        SELECT m.*
+          FROM  config.circ_matrix_matchpoint m
+                /*LEFT*/ JOIN permission.grp_ancestors_distance( user_object.profile ) upgad ON m.grp = upgad.id
+                /*LEFT*/ JOIN actor.org_unit_ancestors_distance( context_ou ) ctoua ON m.org_unit = ctoua.id
+                LEFT JOIN actor.org_unit_ancestors_distance( cn_object.owning_lib ) cnoua ON m.copy_owning_lib = cnoua.id
+                LEFT JOIN actor.org_unit_ancestors_distance( item_object.circ_lib ) iooua ON m.copy_circ_lib = iooua.id
+                LEFT JOIN actor.org_unit_ancestors_distance( user_object.home_ou  ) uhoua ON m.user_home_ou = uhoua.id
+          WHERE m.active
+                -- Permission Groups
+             -- AND (m.grp                      IS NULL OR upgad.id IS NOT NULL) -- Optional Permission Group?
+                -- Org Units
+             -- AND (m.org_unit                 IS NULL OR ctoua.id IS NOT NULL) -- Optional Org Unit?
+                AND (m.copy_owning_lib          IS NULL OR cnoua.id IS NOT NULL)
+                AND (m.copy_circ_lib            IS NULL OR iooua.id IS NOT NULL)
+                AND (m.user_home_ou             IS NULL OR uhoua.id IS NOT NULL)
+                -- Circ Type
+                AND (m.is_renewal               IS NULL OR m.is_renewal = renewal)
+                -- Static User Checks
+                AND (m.juvenile_flag            IS NULL OR m.juvenile_flag = user_object.juvenile)
+                AND (m.usr_age_lower_bound      IS NULL OR (user_age IS NOT NULL AND m.usr_age_lower_bound < user_age))
+                AND (m.usr_age_upper_bound      IS NULL OR (user_age IS NOT NULL AND m.usr_age_upper_bound > user_age))
+                -- Static Item Checks
+                AND (m.circ_modifier            IS NULL OR m.circ_modifier = item_object.circ_modifier)
+                AND (m.marc_type                IS NULL OR m.marc_type = COALESCE(item_object.circ_as_type, rec_descriptor.item_type))
+                AND (m.marc_form                IS NULL OR m.marc_form = rec_descriptor.item_form)
+                AND (m.marc_vr_format           IS NULL OR m.marc_vr_format = rec_descriptor.vr_format)
+                AND (m.ref_flag                 IS NULL OR m.ref_flag = item_object.ref)
+          ORDER BY
+                -- Permission Groups
+                CASE WHEN upgad.distance        IS NOT NULL THEN 2^(2*weights.grp - (upgad.distance/denominator)) ELSE 0.0 END +
+                -- Org Units
+                CASE WHEN ctoua.distance        IS NOT NULL THEN 2^(2*weights.org_unit - (ctoua.distance/denominator)) ELSE 0.0 END +
+                CASE WHEN cnoua.distance        IS NOT NULL THEN 2^(2*weights.copy_owning_lib - (cnoua.distance/denominator)) ELSE 0.0 END +
+                CASE WHEN iooua.distance        IS NOT NULL THEN 2^(2*weights.copy_circ_lib - (iooua.distance/denominator)) ELSE 0.0 END +
+                CASE WHEN uhoua.distance        IS NOT NULL THEN 2^(2*weights.user_home_ou - (uhoua.distance/denominator)) ELSE 0.0 END +
+                -- Circ Type                    -- Note: 4^x is equiv to 2^(2*x)
+                CASE WHEN m.is_renewal          IS NOT NULL THEN 4^weights.is_renewal ELSE 0.0 END +
+                -- Static User Checks
+                CASE WHEN m.juvenile_flag       IS NOT NULL THEN 4^weights.juvenile_flag ELSE 0.0 END +
+                CASE WHEN m.usr_age_lower_bound IS NOT NULL THEN 4^weights.usr_age_lower_bound ELSE 0.0 END +
+                CASE WHEN m.usr_age_upper_bound IS NOT NULL THEN 4^weights.usr_age_upper_bound ELSE 0.0 END +
+                -- Static Item Checks
+                CASE WHEN m.circ_modifier       IS NOT NULL THEN 4^weights.circ_modifier ELSE 0.0 END +
+                CASE WHEN m.marc_type           IS NOT NULL THEN 4^weights.marc_type ELSE 0.0 END +
+                CASE WHEN m.marc_form           IS NOT NULL THEN 4^weights.marc_form ELSE 0.0 END +
+                CASE WHEN m.marc_vr_format      IS NOT NULL THEN 4^weights.marc_vr_format ELSE 0.0 END +
+                CASE WHEN m.ref_flag            IS NOT NULL THEN 4^weights.ref_flag ELSE 0.0 END DESC,
+                -- Final sort on id, so that if two rules have the same sorting in the previous sort they have a defined order
+                -- This prevents "we changed the table order by updating a rule, and we started getting different results"
+                m.id LOOP
+
+        -- Record the full matching row list
+        row_list := row_list || cur_matchpoint.id;
+
+        -- No matchpoint yet?
+        IF matchpoint.id IS NULL THEN
+            -- Take the entire matchpoint as a starting point
+            matchpoint := cur_matchpoint;
+            CONTINUE; -- No need to look at this row any more.
+        END IF;
+
+        -- Incomplete matchpoint?
+        IF matchpoint.circulate IS NULL THEN
+            matchpoint.circulate := cur_matchpoint.circulate;
+        END IF;
+        IF matchpoint.duration_rule IS NULL THEN
+            matchpoint.duration_rule := cur_matchpoint.duration_rule;
+        END IF;
+        IF matchpoint.recurring_fine_rule IS NULL THEN
+            matchpoint.recurring_fine_rule := cur_matchpoint.recurring_fine_rule;
+        END IF;
+        IF matchpoint.max_fine_rule IS NULL THEN
+            matchpoint.max_fine_rule := cur_matchpoint.max_fine_rule;
+        END IF;
+        IF matchpoint.hard_due_date IS NULL THEN
+            matchpoint.hard_due_date := cur_matchpoint.hard_due_date;
+        END IF;
+        IF matchpoint.total_copy_hold_ratio IS NULL THEN
+            matchpoint.total_copy_hold_ratio := cur_matchpoint.total_copy_hold_ratio;
+        END IF;
+        IF matchpoint.available_copy_hold_ratio IS NULL THEN
+            matchpoint.available_copy_hold_ratio := cur_matchpoint.available_copy_hold_ratio;
+        END IF;
+        IF matchpoint.renewals IS NULL THEN
+            matchpoint.renewals := cur_matchpoint.renewals;
+        END IF;
+    END LOOP;
+
+    -- Check required fields
+    IF matchpoint.circulate             IS NOT NULL AND
+       matchpoint.duration_rule         IS NOT NULL AND
+       matchpoint.recurring_fine_rule   IS NOT NULL AND
+       matchpoint.max_fine_rule         IS NOT NULL THEN
+        -- All there? We have a completed match.
+        result.success := true;
+    END IF;
+
+    -- Include the assembled matchpoint, even if it isn't complete
+    result.matchpoint := matchpoint;
+
+    -- Include (for debugging) the full list of matching rows
+    result.buildrows := row_list;
+
+    -- Hand the result back to caller
+    RETURN result;
+END;
+$func$ LANGUAGE plpgsql;
+
+-- Helper function - For manual calling, it can be easier to pass in IDs instead of objects
+CREATE OR REPLACE FUNCTION action.find_circ_matrix_matchpoint( context_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.found_circ_matrix_matchpoint AS $func$
+DECLARE
+    item_object asset.copy%ROWTYPE;
+    user_object actor.usr%ROWTYPE;
+BEGIN
+    SELECT INTO item_object * FROM asset.copy  WHERE id = match_item;
+    SELECT INTO user_object * FROM actor.usr   WHERE id = match_user;
+
+    RETURN QUERY SELECT * FROM action.find_circ_matrix_matchpoint( context_ou, item_object, user_object, renewal );
+END;
+$func$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION action.item_user_circ_test( circ_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.circ_matrix_test_result AS $func$
+DECLARE
+    user_object             actor.usr%ROWTYPE;
+    standing_penalty        config.standing_penalty%ROWTYPE;
+    item_object             asset.copy%ROWTYPE;
+    item_status_object      config.copy_status%ROWTYPE;
+    item_location_object    asset.copy_location%ROWTYPE;
+    result                  action.circ_matrix_test_result;
+    circ_test               action.found_circ_matrix_matchpoint;
+    circ_matchpoint         config.circ_matrix_matchpoint%ROWTYPE;
+    out_by_circ_mod         config.circ_matrix_circ_mod_test%ROWTYPE;
+    circ_mod_map            config.circ_matrix_circ_mod_test_map%ROWTYPE;
+    hold_ratio              action.hold_stats%ROWTYPE;
+    penalty_type            TEXT;
+    items_out               INT;
+    context_org_list        INT[];
+    done                    BOOL := FALSE;
+BEGIN
+    -- Assume success unless we hit a failure condition
+    result.success := TRUE;
+
+    -- Fail if the user is BARRED
+    SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
+
+    -- Fail if we couldn't find the user 
+    IF user_object.id IS NULL THEN
+        result.fail_part := 'no_user';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+        RETURN;
+    END IF;
+
+    SELECT INTO item_object * FROM asset.copy WHERE id = match_item;
+
+    -- Fail if we couldn't find the item 
+    IF item_object.id IS NULL THEN
+        result.fail_part := 'no_item';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+        RETURN;
+    END IF;
+
+    IF user_object.barred IS TRUE THEN
+        result.fail_part := 'actor.usr.barred';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the item can't circulate
+    IF item_object.circulate IS FALSE THEN
+        result.fail_part := 'asset.copy.circulate';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the item isn't in a circulateable status on a non-renewal
+    IF NOT renewal AND item_object.status NOT IN ( 0, 7, 8 ) THEN 
+        result.fail_part := 'asset.copy.status';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    ELSIF renewal AND item_object.status <> 1 THEN
+        result.fail_part := 'asset.copy.status';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the item can't circulate because of the shelving location
+    SELECT INTO item_location_object * FROM asset.copy_location WHERE id = item_object.location;
+    IF item_location_object.circulate IS FALSE THEN
+        result.fail_part := 'asset.copy_location.circulate';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    SELECT INTO circ_test * FROM action.find_circ_matrix_matchpoint(circ_ou, item_object, user_object, renewal);
+
+    circ_matchpoint             := circ_test.matchpoint;
+    result.matchpoint           := circ_matchpoint.id;
+    result.circulate            := circ_matchpoint.circulate;
+    result.duration_rule        := circ_matchpoint.duration_rule;
+    result.recurring_fine_rule  := circ_matchpoint.recurring_fine_rule;
+    result.max_fine_rule        := circ_matchpoint.max_fine_rule;
+    result.hard_due_date        := circ_matchpoint.hard_due_date;
+    result.renewals             := circ_matchpoint.renewals;
+    result.buildrows            := circ_test.buildrows;
+
+    -- Fail if we couldn't find a matchpoint
+    IF circ_test.success = false THEN
+        result.fail_part := 'no_matchpoint';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+        RETURN; -- All tests after this point require a matchpoint. No sense in running on an incomplete or missing one.
+    END IF;
+
+    -- Apparently....use the circ matchpoint org unit to determine what org units are valid.
+    SELECT INTO context_org_list ARRAY_ACCUM(id) FROM actor.org_unit_full_path( circ_matchpoint.org_unit );
+
+    IF renewal THEN
+        penalty_type = '%RENEW%';
+    ELSE
+        penalty_type = '%CIRC%';
+    END IF;
+
+    FOR standing_penalty IN
+        SELECT  DISTINCT csp.*
+          FROM  actor.usr_standing_penalty usp
+                JOIN config.standing_penalty csp ON (csp.id = usp.standing_penalty)
+          WHERE usr = match_user
+                AND usp.org_unit IN ( SELECT * FROM unnest(context_org_list) )
+                AND (usp.stop_date IS NULL or usp.stop_date > NOW())
+                AND csp.block_list LIKE penalty_type LOOP
+
+        result.fail_part := standing_penalty.name;
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END LOOP;
+
+    -- Fail if the test is set to hard non-circulating
+    IF circ_matchpoint.circulate IS FALSE THEN
+        result.fail_part := 'config.circ_matrix_test.circulate';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the total copy-hold ratio is too low
+    IF circ_matchpoint.total_copy_hold_ratio IS NOT NULL THEN
+        SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
+        IF hold_ratio.total_copy_ratio IS NOT NULL AND hold_ratio.total_copy_ratio < circ_matchpoint.total_copy_hold_ratio THEN
+            result.fail_part := 'config.circ_matrix_test.total_copy_hold_ratio';
+            result.success := FALSE;
+            done := TRUE;
+            RETURN NEXT result;
+        END IF;
+    END IF;
+
+    -- Fail if the available copy-hold ratio is too low
+    IF circ_matchpoint.available_copy_hold_ratio IS NOT NULL THEN
+        IF hold_ratio.hold_count IS NULL THEN
+            SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
+        END IF;
+        IF hold_ratio.available_copy_ratio IS NOT NULL AND hold_ratio.available_copy_ratio < circ_matchpoint.available_copy_hold_ratio THEN
+            result.fail_part := 'config.circ_matrix_test.available_copy_hold_ratio';
+            result.success := FALSE;
+            done := TRUE;
+            RETURN NEXT result;
+        END IF;
+    END IF;
+
+    -- Fail if the user has too many items with specific circ_modifiers checked out
+    FOR out_by_circ_mod IN SELECT * FROM config.circ_matrix_circ_mod_test WHERE matchpoint = circ_matchpoint.id LOOP
+        SELECT  INTO items_out COUNT(*)
+          FROM  action.circulation circ
+            JOIN asset.copy cp ON (cp.id = circ.target_copy)
+          WHERE circ.usr = match_user
+               AND circ.circ_lib IN ( SELECT * FROM unnest(context_org_list) )
+            AND circ.checkin_time IS NULL
+            AND (circ.stop_fines IN ('MAXFINES','LONGOVERDUE') OR circ.stop_fines IS NULL)
+            AND cp.circ_modifier IN (SELECT circ_mod FROM config.circ_matrix_circ_mod_test_map WHERE circ_mod_test = out_by_circ_mod.id);
+        IF items_out >= out_by_circ_mod.items_out THEN
+            result.fail_part := 'config.circ_matrix_circ_mod_test';
+            result.success := FALSE;
+            done := TRUE;
+            RETURN NEXT result;
+        END IF;
+    END LOOP;
+
+    -- If we passed everything, return the successful matchpoint id
+    IF NOT done THEN
+        RETURN NEXT result;
+    END IF;
+
+    RETURN;
+END;
+$func$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION action.item_user_circ_test( INT, BIGINT, INT ) RETURNS SETOF action.circ_matrix_test_result AS $func$
+    SELECT * FROM action.item_user_circ_test( $1, $2, $3, FALSE );
+$func$ LANGUAGE SQL;
+
+CREATE OR REPLACE FUNCTION action.item_user_renew_test( INT, BIGINT, INT ) RETURNS SETOF action.circ_matrix_test_result AS $func$
+    SELECT * FROM action.item_user_circ_test( $1, $2, $3, TRUE );
+$func$ LANGUAGE SQL;
+
+COMMIT;
index ba05bc1..1db121a 100644 (file)
@@ -142,6 +142,14 @@ if(!dojo._hasResource['openils.widget.AutoFieldWidget']) {
          */
         getDisplayString : function() {
             var value = this.widgetValue;
+            if(this.inherits) {
+                switch(value) {
+                    case null :
+                    case undefined :
+                    case 'unset' :
+                        return openils.widget.AutoFieldWidget.localeStrings.INHERITED;
+                }
+            }
             switch(this.idlField.datatype) {
                 case 'bool':
                     switch(value) {
@@ -272,13 +280,13 @@ if(!dojo._hasResource['openils.widget.AutoFieldWidget']) {
                         break;
 
                     case 'bool':
-                        if(this.ternary) {
+                        if(this.ternary || this.inherits) {
                             dojo.require('dijit.form.FilteringSelect');
                             var store = new dojo.data.ItemFileReadStore({
                                 data:{
                                     identifier : 'value',
                                     items:[
-                                        {label : openils.widget.AutoFieldWidget.localeStrings.UNSET, value : 'unset'},
+                                        {label : (this.inherits ? openils.widget.AutoFieldWidget.localeStrings.INHERITED : openils.widget.AutoFieldWidget.localeStrings.UNSET), value : 'unset'},
                                         {label : openils.widget.AutoFieldWidget.localeStrings.TRUE, value : 'true'},
                                         {label : openils.widget.AutoFieldWidget.localeStrings.FALSE, value : 'false'}
                                     ]
index 94f24bf..3e99b28 100644 (file)
@@ -611,15 +611,17 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
 
     openils.widget.AutoGrid.defaultGetter = function(rowIndex, item) {
         if(!item) return '';
+        if(!this.grid.overrideWidgetArgs[this.field])
+            this.grid.overrideWidgetArgs[this.field] = {};
         var val = this.grid.store.getValue(item, this.field);
-        var autoWidget = new openils.widget.AutoFieldWidget({
+        var autoWidget = new openils.widget.AutoFieldWidget(dojo.mixin({
             fmClass: this.grid.fmClass,
             fmField: this.field,
             widgetValue : val,
             readOnly : true,
             forceSync : true, // prevents many simultaneous requests for the same data
             suppressLinkedFields : this.grid.suppressLinkedFields
-        });
+        },this.grid.overrideWidgetArgs[this.field]));
 
         autoWidget.build();
 
index e3262de..1fd41ad 100644 (file)
@@ -77,6 +77,20 @@ if(!dojo._hasResource['openils.widget.EditPane']) {
                     if(field.name == this.fmIDL.pkey && this.mode == 'create' && this.fmIDL.pkey_sequence)
                         continue; /* don't show auto-generated fields on create */
 
+                    if(!this.overrideWidgetArgs[field.name])
+                        this.overrideWidgetArgs[field.name] = {};
+
+                    if(this.overrideWidgetArgs[field.name].hrbefore && this.paneStackCount <= 1) {
+                        var hrTr = document.createElement('tr');
+                        var hrTd = document.createElement('td');
+                        var hr = document.createElement('hr');
+                        hrTd.colSpan = 2;
+                        dojo.addClass(hrTd, 'openils-widget-editpane-hr-cell');
+                        hrTd.appendChild(hr);
+                        hrTr.appendChild(hrTd);
+                        tbody.appendChild(hrTr);
+                    }
+
                     if((idx++ % this.paneStackCount) == 0 || !currentRow) {
                         // time to start a new row
                         currentRow = document.createElement('tr');
@@ -107,9 +121,6 @@ if(!dojo._hasResource['openils.widget.EditPane']) {
                     currentRow.appendChild(valTd);
                     //dojo.addClass(docTd, 'oils-fm-edit-pane-help');
 
-                    if(!this.overrideWidgetArgs[field.name])
-                        this.overrideWidgetArgs[field.name] = {};
-
                     var args = dojo.mixin(
                         {   // defaults
                             idlField : field, 
index 4d19f34..bc77c06 100644 (file)
@@ -1,5 +1,6 @@
 {
     "TRUE" : "True",
     "FALSE" : "False",
-    "UNSET" : "Unset"
+    "UNSET" : "Unset",
+    "INHERITED" : "Inherited"
 }
index 7fdae7b..f5d5b3a 100644 (file)
@@ -12,7 +12,18 @@ var circModEntryCache = {};
 var matchPoint;
 
 function load(){
+    cmGrid.overrideWidgetArgs.grp = {hrbefore : true};
     cmGrid.overrideWidgetArgs.is_renewal = {ternary : true};
+    cmGrid.overrideWidgetArgs.ref_flag = {ternary : true};
+    cmGrid.overrideWidgetArgs.juvenile_flag = {ternary : true};
+    cmGrid.overrideWidgetArgs.circulate = {inherits : true, hrbefore : true};
+    cmGrid.overrideWidgetArgs.duration_rule = {inherits : true};
+    cmGrid.overrideWidgetArgs.recurring_fine_rule = {inherits : true};
+    cmGrid.overrideWidgetArgs.max_fine_rule = {inherits : true};
+    cmGrid.overrideWidgetArgs.available_copy_hold_ratio = {inherits : true};
+    cmGrid.overrideWidgetArgs.total_copy_hold_ratio = {inherits : true};
+    cmGrid.overrideWidgetArgs.renewals = {inherits : true};
+    cmGrid.overrideWidgetArgs.hard_due_date = {inherits : true};
     cmGrid.loadAll({order_by:{ccmm:'circ_modifier'}});
     cmGrid.onEditPane = buildEditPaneAdditions;
     circModEditor = dojo.byId('circ-mod-editor').parentNode.removeChild(dojo.byId('circ-mod-editor'));
index 6054078..c09012f 100644 (file)
@@ -9,7 +9,7 @@
     <table  jsId="cmGrid"
             style="height: 600px;"
             dojoType="openils.widget.AutoGrid"
-            fieldOrder="['org_unit', 'active', 'grp', 'circ_modifier', 'marc_type', 'marc_form', 'marc_vr_format']"
+            fieldOrder="['id', 'active', 'grp', 'org_unit', 'copy_circ_lib', 'copy_owning_lib', 'user_home_ou', 'is_renewal', 'juvenile_flag', 'circ_modifier', 'marc_type', 'marc_form', 'marc_vr_format', 'ref_flag', 'usr_age_lower_bound', 'usr_age_upper_bound', 'circulate', 'duration_rule', 'renewals', 'hard_due_date', 'recurring_fine_rule', 'max_fine_rule', 'available_copy_hold_ratio', 'total_copy_hold_ratio', 'script_test']"
             defaultCellWidth='"auto"'
             query="{id: '*'}"
             fmClass='ccmm'
 <div class='hidden'><div dojoType='openils.widget.ProgressDialog' jsId='progressDialog'/></div>
 
 <script type="text/javascript">
-    function format_hard_due_date(name) {
-        return "<a href='" + oilsBasePath +
-            "/conify/global/config/hard_due_date?name=" +
-            encodeURIComponent(name) + "'>" + name + "</a>";
+    function format_hard_due_date(name, id) {
+        var item=this.grid.getItem(id);
+        if(!item) return name;
+        switch (this.grid.store.getValue(this.grid.getItem(id), 'hard_due_date')) {
+            case null :
+            case undefined :
+            case 'unset' :
+                return name;
+            default:
+                return "<a href='" + oilsBasePath +
+                    "/conify/global/config/hard_due_date?name=" +
+                    encodeURIComponent(name) + "'>" + name + "</a>";
+        }
     }
 </script>
 [% END %]