Smart Float automates the redistribution of floating collections based on available...
authorDoug Kyle <dkyle@grpl.org>
Wed, 2 Apr 2014 15:14:51 +0000 (11:14 -0400)
committerDoug Kyle <dkyle@grpl.org>
Wed, 2 Apr 2014 15:14:51 +0000 (11:14 -0400)
"re-homing" of items based on a number of circulations from the original library or a specified time frame.

Upon checkin Smart Float will first check if an item needs to be sent home, if not the item will float to the checkin branch if space is available
and there are not too many duplicate titles.  If space and or duplicates don't allow floating to the checkin branch, it will first float to the
branch with most space and no excess duplicates, next to the branch with most space regardless of duplicates.
If no branches have shelf space it will stay where it is (float to the checkin library).

Copy locations are the units of Smart Float operation. Copy locations are mapped to the following attributes that Smart Float uses to determine how to distribute copies.
- active: if true, Smart Float will be used, otherwise traditional floating will occur.
- items_allowed: the number of items the shelving location can hold.
- dups_threshold: the number of duplicate titles allowed for that copy location.
- homing_threshold: number of circulations from the original owning library.
- homing_lifespan: time interval, such as "3 months".
Items will be sent home within the homing_lifespan from creation date, until the homing_threshold is met.
So if shelving location 'kids movies' has a homing_threshold of 1 and a homing_lifespan of "3 months", items in that shelving location won't start floating
until they have circulated once from the owning library, or its been 3 months or more since they were created.

Signed-off-by: Doug Kyle <dkyle@grpl.org>
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
Open-ILS/src/sql/Pg/smart_float.sql [new file with mode: 0644]

index 5f73860..36c26c8 100644 (file)
@@ -2643,6 +2643,11 @@ sub do_checkin {
 
     my $needed_for_something = 0; # formerly "needed_for_hold"
 
+    my $sf_use = $self->editor->json_query({ from => ['smart_float.in_use'] });
+    my $smart_float_in_use = $U->is_true($sf_use->[0]->{'smart_float.in_use'}) if $sf_use;
+    my $sf_libs = $self->editor->json_query({ from => ['smart_float.libs', $self->circ_lib] });
+    my $is_smart_float_lib = $U->is_true($sf_libs->[0]->{'smart_float.libs'}) if $sf_libs;
+
     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
 
         if (!$self->remote_hold) {
@@ -2695,13 +2700,12 @@ sub do_checkin {
                     }
                 }
             }
-            if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
+
+           if ($smart_float_in_use) { # Begin Smart Float 
+            if( $suppress_transit or ( ($circ_lib == $self->circ_lib and !$is_smart_float_lib) and not ($self->hold_as_transit and $self->remote_hold) ) ) {
                 # copy is where it needs to be, either for hold or reshelving
-    
                 $self->checkin_handle_precat();
                 return if $self->bail_out;
-    
             } else {
                 # copy needs to transit "home", or stick here if it's a floating copy
                 my $can_float = 0;
@@ -2718,6 +2722,46 @@ sub do_checkin {
                     $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res; 
                 }
                 if ($can_float) { # Yep, floating, stick here
+                   my $sf_dest = $self->editor->json_query({ from => ['smart_float.destination', $self->circ_lib, $self->copy->id] })->[0];
+                   $self->copy->circ_lib( [values %$sf_dest]->[0] );
+                   $self->update_copy;
+                   $logger->error("SMARTFLOAT: floating checkin lib: ".$self->circ_lib." copy id: ".$self->copy->id." new lib ".[values %$sf_dest]->[0]);
+                   if ($self->copy->circ_lib != $self->circ_lib) { # if transit needed
+                               $self->checkin_build_copy_transit($self->copy->circ_lib);
+                               return if $self->bail_out;
+                               $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $self->copy->circ_lib));
+                   }
+                } else {
+                   $logger->error("SMARTFLOAT: manual float is ".$self->manual_float." copy ".$self->copy->id." circlib is ".$self->copy->circ_lib." float is ".$self->copy->floating->name." checkin lib ".$self->circ_lib);
+                    if ($self->copy->circ_lib != $self->circ_lib) { # if transit needed
+                        $self->checkin_build_copy_transit($self->copy->circ_lib);
+                        return if $self->bail_out;
+                        $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
+                    }
+                }
+            } # end Smart Float
+           } else {
+           # smart float not in use
+               if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
+                # copy is where it needs to be, either for hold or reshelving
+                $self->checkin_handle_precat();
+                return if $self->bail_out;
+            } else {
+                # copy needs to transit "home", or stick here if it's a floating copy
+                my $can_float = 0;
+                if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
+                    my $res = $self->editor->json_query(
+                        {   from => [
+                                'evergreen.can_float',
+                                $self->copy->floating->id,
+                                $self->copy->circ_lib,
+                                $self->circ_lib
+                            ]
+                        }
+                    );
+                    $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
+                }
+                if ($can_float) { # Yep, floating, stick here
                     $self->checkin_changed(1);
                     $self->copy->circ_lib( $self->circ_lib );
                     $self->update_copy;
@@ -2729,13 +2773,18 @@ sub do_checkin {
                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
                 }
             }
-        }
+        } # end smart float not in use
+      } # end unless needed for something
     } else { # no-op checkin
-        if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
+      if ($smart_float_in_use) {    
+        if ($U->is_true( $self->copy->floating ) and $is_smart_float_lib) { # XXX floating items still stick where they are even with no-op checkin?
             $self->checkin_changed(1);
             $self->copy->circ_lib( $self->circ_lib );
             $self->update_copy;
         }
+      } else {
+       # smart float not in use
+      }
     }
 
     if($self->claims_never_checked_out and 
diff --git a/Open-ILS/src/sql/Pg/smart_float.sql b/Open-ILS/src/sql/Pg/smart_float.sql
new file mode 100644 (file)
index 0000000..2bd6dd4
--- /dev/null
@@ -0,0 +1,250 @@
+Create schema smart_float;
+
+-- !!! config.smart_float holds Smart Float configuration parameters !!!
+DROP TABLE IF EXISTS config.smart_float;
+CREATE TABLE config.smart_float (
+id SERIAL PRIMARY KEY,
+name TEXT,
+active BOOL NOT NULL DEFAULT FALSE,
+dups_threshold INT NOT NULL DEFAULT 0,
+org_unit INT NOT NULL DEFAULT 9,
+shelf_location INT NOT NULL,
+shelf_is_group BOOL NOT NULL DEFAULT FALSE,
+items_allowed INT,
+homing_threshold INT NOT NULL DEFAULT 0,
+homing_lifespan TEXT DEFAULT '1 day',
+CONSTRAINT org_unit_fkey  FOREIGN KEY (org_unit) REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
+CONSTRAINT shelf_location_fkey  FOREIGN KEY (shelf_location) REFERENCES asset.copy_location (id) DEFERRABLE INITIALLY DEFERRED,
+CONSTRAINT org_plus_loc UNIQUE(org_unit,shelf_location)
+);
+
+-- !!! smart_float.dups !!! 
+CREATE OR REPLACE FUNCTION smart_float.dups(bcode text)
+ RETURNS TABLE(clib integer, numdups bigint)
+ LANGUAGE plpgsql
+AS $function$
+BEGIN
+    RETURN QUERY with sflibs as (
+        select org_unit from config.smart_float where active is true group by 1
+),
+duplibs as (
+select circ_lib,count(circ_lib) from asset.copy where barcode != bcode and status in (0,7) and call_number in (select id from asset.call_number where record in (select record from asset.call_number join asset.copy on call_number.id=copy.call_number and copy.barcode=bcode)) group by 1
+)
+select sflibs.org_unit,coalesce(duplibs.count,0) as count from sflibs left join duplibs on sflibs.org_unit=duplibs.circ_lib;
+END;
+$function$;
+
+-- !!! smart_float.opens_and_dups !!! 
+CREATE OR REPLACE FUNCTION smart_float.opens_and_dups(bcode text, cloc integer)
+ RETURNS TABLE(clib integer, taken bigint, allowed integer, open bigint, dups bigint, dups_threshold integer)
+ LANGUAGE plpgsql
+AS $function$
+BEGIN
+    RETURN QUERY select copy.circ_lib,count(copy.id),sf.items_allowed,(sf.items_allowed-count(copy.id)) as spaces,(select numdups from smart_float.dups(bcode) where smart_float_dups.clib=copy.circ_lib) as dups, sf.dups_threshold from asset.copy join config.smart_float sf on (copy.circ_lib=sf.org_unit and copy.location=sf.shelf_location and sf.active is true) where copy.location=cloc and copy.status in (0,7) group by circ_lib,sf.items_allowed,sf.dups_threshold;
+END;
+$function$;
+
+
+DROP TABLE IF EXISTS smart_float.metrics;
+CREATE TABLE smart_float.metrics (
+id SERIAL PRIMARY KEY,
+sf_time TIMESTAMP DEFAULT now(),
+copy_id BIGINT,
+prev_lib INT,
+owning_lib INT,
+chkin_lib INT,
+open_space INT,
+duplicates INT,
+new_lib INT,
+op_code TEXT,
+sla_name TEXT DEFAULT NULL,
+sla_dups_threshold INT DEFAULT NULL,
+sla_org_unit INT DEFAULT NULL,
+sla_shelf_location INT DEFAULT NULL,
+sla_items_allowed INT DEFAULT NULL,
+sla_homing_threshold INT DEFAULT NULL,
+sla_homing_lifespan TEXT DEFAULT NULL,
+homing_circs_needed INT DEFAULT NULL,
+homing_days_to_go INT DEFAULT NULL
+);
+
+DROP TABLE IF EXISTS smart_float.cap_store;
+CREATE TABLE smart_float.cap_store (
+id SERIAL PRIMARY KEY,
+branch text,
+taken integer,
+allowed integer,
+open integer,
+percent_capacity numeric,
+name text,
+shelf_loc integer,
+cap_date date
+);
+
+
+CREATE OR REPLACE FUNCTION smart_float.save_metric(cpid bigint,pvlib int,onlib int,cklib int,opens int,dups int,nwlib int,opcd text,shelf_loc_atts config.smart_float, hcn int, hdtg int)
+ RETURNS void
+ LANGUAGE plpgsql
+AS $function$
+BEGIN
+        IF shelf_loc_atts IS NULL THEN
+                 INSERT INTO smart_float.metrics (copy_id,prev_lib,owning_lib,chkin_lib,open_space,duplicates,new_lib,op_code)
+                        VALUES(cpid,pvlib,onlib,cklib,opens,dups,nwlib,opcd);
+        ELSE
+                INSERT INTO smart_float.metrics
+                        VALUES(default,default,cpid,pvlib,onlib,cklib,opens,dups,nwlib,opcd,$9.name,$9.dups_threshold,$9.org_unit,$9.shelf_location,$9.items_allowed,$9.homing_threshold,$9.homing_lifespan,hcn,hdtg);
+        END IF;
+
+END;
+$function$;
+
+
+
+-- !!!  smart_float.destination !!!
+CREATE OR REPLACE FUNCTION smart_float.destination(chkin_org integer, copy_id bigint)
+ RETURNS integer
+ LANGUAGE plpgsql
+AS $function$
+
+DECLARE
+    copy_rec asset.copy%ROWTYPE;
+    shelf_loc_atts config.smart_float%ROWTYPE;
+    origin_loc_atts config.smart_float%ROWTYPE;
+    origin_owner INT;
+    send_lib INT;
+    homing_threshold_met BOOL;
+    circs_needed INT;
+    days_to_go INT;    
+    open_space INT;
+    duplicates INT;
+    opcode TEXT;
+
+BEGIN
+    SELECT INTO copy_rec * FROM asset.copy WHERE id = copy_id;
+    SELECT INTO origin_owner owning_lib from asset.call_number join asset.copy on call_number.id=copy.call_number where copy.id=copy_id;
+    SELECT INTO shelf_loc_atts * FROM config.smart_float WHERE org_unit = chkin_org and shelf_location = copy_rec.location and active is true;
+    IF NOT FOUND THEN
+          -- no smart_float config for this org and shelf, so dumb float it here
+          PERFORM smart_float.save_metric(copy_rec.id,copy_rec.circ_lib,origin_owner,chkin_org,NULL,NULL,chkin_org,'not_active',NULL,NULL,NULL);
+          RETURN chkin_org;
+    END IF;
+
+    SELECT INTO origin_loc_atts * FROM config.smart_float WHERE org_unit = origin_owner and shelf_location = copy_rec.location;
+    SELECT INTO homing_threshold_met,circs_needed,days_to_go * FROM smart_float.homing_threshold_met(copy_id,origin_owner,origin_loc_atts.homing_threshold,origin_loc_atts.homing_lifespan,copy_rec.active_date);
+
+   IF homing_threshold_met IS FALSE THEN
+        -- send home, homing threshold not met
+        PERFORM smart_float.save_metric(copy_rec.id,copy_rec.circ_lib,origin_owner,chkin_org,NULL,NULL,NULL,'homed',origin_loc_atts.*,circs_needed,days_to_go);
+        RETURN origin_owner;
+    END IF;
+
+    -- can item float to checkin lib? aka the shelving location is not full and has less than dups_threshold duplicate titles?
+    opcode := 'floated';
+    SELECT INTO send_lib,open_space,duplicates o.clib,o.open,d.numdups from smart_float.openings(copy_rec.location) as o join smart_float.dups(copy_rec.barcode) as d on d.clib=o.clib where o.clib=chkin_org and o.open>0 and (d.numdups<o.dups_threshold or d.numdups=0);
+    IF NOT FOUND THEN
+        -- can item bounce to another lib?
+        opcode := 'bounced';
+        SELECT INTO send_lib,open_space,duplicates o.clib,o.open,d.numdups from smart_float.openings(copy_rec.location) as o join smart_float.dups(copy_rec.barcode) as d on d.clib=o.clib where o.open>0 and (d.numdups<o.dups_threshold or d.numdups=0) AND evergreen.can_float(copy_rec.floating,copy_rec.circ_lib,o.clib) is TRUE order by o.open desc limit 1;
+        IF NOT FOUND THEN
+                -- can item float here or bounce elsewhere regardless of dups?
+                opcode := 'open_only_bounced';
+                SELECT INTO send_lib,open_space clib,open from smart_float.openings(copy_rec.location) where open>0 AND evergreen.can_float(copy_rec.floating,copy_rec.circ_lib,clib) is TRUE order by open desc limit 1;
+                IF NOT FOUND THEN
+                          -- otherwise no sense in transiting item
+                          -- floating to checkin lib because it can't go anywhere else
+                          opcode := 'nogo_floated';
+                          send_lib := chkin_org;
+                END IF;
+        END IF;
+    END IF;
+    PERFORM smart_float.save_metric(copy_rec.id,copy_rec.circ_lib,origin_owner,chkin_org,open_space,duplicates,send_lib,opcode,shelf_loc_atts.*,NULL,NULL);
+    RETURN send_lib;
+END;
+$function$;
+
+-- !!! smart_float.homing_threshold_met !!!
+CREATE OR REPLACE FUNCTION smart_float.homing_threshold_met(cpid bigint, owning integer, threshold integer, lifespan text, active timestamp with time zone)
+ RETURNS TABLE(homing_met BOOL, circs_needed INT, days_to_go INT)
+ LANGUAGE plpgsql
+AS $function$
+BEGIN
+  RETURN QUERY SELECT (((select count(*) from action.all_circulation where target_copy=cpid and circ_lib=owning and phone_renewal='f' and desk_renewal='f' and opac_renewal='f') >= threshold) AND (select active + lifespan::interval < now())),
+(threshold - (select count(*) from action.all_circulation where target_copy=cpid and circ_lib=owning and phone_renewal='f' and desk_renewal='f' and opac_renewal='f'))::INT, 
+(select max(dtg) 
+from (
+        select date_part('Days',(select (active + lifespan::INTERVAL) - now()))::INT as dtg
+        union select 0
+) d);
+END;
+$function$;
+
+-- !!! smart_float.openings !!!
+CREATE OR REPLACE FUNCTION smart_float.openings(cloc integer)
+ RETURNS TABLE(clib integer, taken bigint, allowed integer, open bigint, dups_threshold integer)
+ LANGUAGE plpgsql
+AS $function$
+BEGIN
+ RETURN QUERY with tcs as (                                                                                                                                                                 Select library,arriving from smart_float.shelving_location_non_hold_transit_vectors(cloc)
+)
+select copy.circ_lib,count(copy.id),sf.items_allowed,(sf.items_allowed-count(copy.id))-tcs.arriving as spaces, sf.dups_threshold from asset.copy join config.smart_float sf on (copy.circ_lib=sf.org_unit and copy.location=sf.shelf_location) join tcs on tcs.library=copy.circ_lib where copy.location=cloc and copy.status in (0,7) group by circ_lib,sf.items_allowed,sf.dups_threshold,tcs.arriving;
+END;
+$function$;
+
+-- !!! smart_float.capacities !!!
+CREATE OR REPLACE FUNCTION smart_float.capacities()
+ RETURNS TABLE(branch text, taken integer, allowed integer, spaces integer, percent_capacity numeric, name text, shelf_loc integer)
+ LANGUAGE plpgsql
+AS $function$
+DECLARE
+        ts INT;
+BEGIN
+    FOR ts IN select distinct(shelf_location) from config.smart_float order by 1 LOOP
+        RETURN QUERY
+                select aou.shortname,count(copy.id)::INT,sf.items_allowed,(sf.items_allowed-count(copy.id))::INT as spaces, round(count(copy.id)/sf.items_allowed::numeric*100.0,1), sf.name, sf.shelf_location from asset.copy join config.smart_float sf on (copy.circ_lib=sf.org_unit and copy.location=sf.shelf_location and sf.items_allowed != 0) join actor.org_unit aou on copy.circ_lib=aou.id where copy.location=ts and copy.status in (0,7)  group by shortname,sf.items_allowed,sf.name,sf.shelf_location order by sf.name,aou.shortname;
+    END LOOP;
+END;
+$function$;
+
+
+-- !!! smart_float.shelving_location_non_hold_transit_vectors !!!
+CREATE OR REPLACE FUNCTION smart_float.shelving_location_non_hold_transit_vectors(sloc integer)
+ RETURNS TABLE(library integer, departing bigint, arriving bigint, transit_vector bigint)
+ LANGUAGE plpgsql
+AS $function$
+BEGIN
+RETURN QUERY with transits as (
+        select atc.id,source_send_time, dest_recv_time,target_copy,source,dest from action.transit_copy atc join asset.copy cp on atc.target_copy=cp.id and cp.location=sloc where dest_recv_time is null EXCEPT select id,source_send_time, dest_recv_time,target_copy,source,dest from action.hold_transit_copy where dest_recv_time is null
+),
+tfrom as (
+        select source from_lib,count(source) from_count from transits group by 1 order by 1
+),
+tto as (
+        select dest to_lib,count(dest) to_count from transits group by 1 order by 1
+),
+orgs as (
+        select id from actor.org_unit where id between 10 and 17
+)
+select orgs.id as lib,coalesce(from_count,0),coalesce(to_count,0),coalesce(to_count,0)-coalesce(from_count,0) as transit_vector from orgs left join tto on orgs.id=to_lib left join tfrom on orgs.id=from_lib;
+END;
+$function$;
+
+CREATE OR REPLACE FUNCTION smart_float.libs(chkin_lib INT)
+ RETURNS BOOLEAN
+ LANGUAGE plpgsql
+AS $function$
+BEGIN
+    RETURN chkin_lib in (select org_unit from config.smart_float where active is true group by 1);
+END;
+
+CREATE OR REPLACE FUNCTION smart_float.in_use()
+ RETURNS boolean
+ LANGUAGE plpgsql
+AS $function$
+BEGIN
+    RETURN TRUE IN (SELECT active FROM config.smart_float);
+END;
+$function$;
+
+-- !!! need some new indexes
+create index concurrently cp_location_idx on asset.copy (location);
+create index concurrently cp_smart_float_dup_counts_idx on asset.copy (barcode);