merge vincinity stuff into geo app, revert changes to admin page component, fix syntax error in statement to create SQL
add call to geo from actor like how the retrieve coordinates works
get distance divisor from config global flags and divide shipping
distance by it to get copy score.
<link field="name" reltype="has_a" key="name" map="" class="coust"/>
</links>
</class>
+ <class id="aoushd" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::org_unit_shipping_hub_distance" oils_persist:tablename="actor.org_unit_shipping_hub_distance" reporter:label="Organizational Unit Shipping Hub Distance">
+ <fields oils_persist:primary="id" oils_persist:sequence="actor.org_unit_shipping_hub_distance_id_seq">
+ <field name="id" />
+ <field name="orig_hub" reporter:datatype="org_unit"/>
+ <field name="dest_hub" reporter:datatype="org_unit"/>
+ <field name="distance" reporter:datatype="int"/>
+ </fields>
+ <links>
+ <link field="orig_hub" reltype="has_a" key="id" map="" class="aou"/>
+ <link field="dest_hub" reltype="has_a" key="id" map="" class="aou"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="UPDATE_ORG_UNIT" context_field="orig_hub"/>
+ <retrieve/>
+ <update permission="UPDATE_ORG_UNIT" context_field="orig_hub"/>
+ <delete permission="UPDATE_ORG_UNIT" context_field="orig_hub"/>
+ </actions>
+ </permacrud>
+ </class>
<class id="acpn" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy_note" oils_persist:tablename="asset.copy_note" reporter:label="Copy Note">
<fields oils_persist:primary="id" oils_persist:sequence="asset.copy_note_id_seq">
<field reporter:label="Note Creation Date/Time" name="create_date" reporter:datatype="timestamp"/>
<field name="id" reporter:datatype="id" />
<field name="target_copy" reporter:datatype="link"/>
<field name="proximity" reporter:datatype="number"/>
+ <field name="shipping_distance" reporter:datatype="number"/>
</fields>
<links>
<link field="hold" reltype="has_a" key="id" map="" class="ahr"/>
<field reporter:label="Name" name="name" reporter:datatype="text" oils_persist:i18n="true" oils_obj:required="true"/>
<field reporter:label="Organizational Unit Type" name="ou_type" reporter:datatype="link" oils_obj:required="true"/>
<field reporter:label="Parent Organizational Unit" name="parent_ou" reporter:datatype="link"/>
+ <field reporter:label="Shipping Hub Unit" name="shipping_hub_ou" reporter:datatype="link"/>
<field reporter:label="Short (Policy) Name" name="shortname" reporter:datatype="text" oils_obj:required="true" oils_obj:validate="^.+$"/>
<field reporter:label="Email Address" name="email" reporter:datatype="text"/>
<field reporter:label="Phone Number" name="phone" reporter:datatype="text"/>
<link field="ou_type" reltype="has_a" key="id" map="" class="aout"/>
<link field="mailing_address" reltype="has_a" key="id" map="" class="aoa"/>
<link field="parent_ou" reltype="has_a" key="id" map="" class="aou"/>
+ <link field="shipping_hub_ou" reltype="has_a" key="id" map="" class="aou"/>
<link field="ill_address" reltype="has_a" key="id" map="" class="aoa"/>
<link field="fiscal_calendar" reltype="has_a" key="id" map="" class="acqfc"/>
<link field="users" reltype="has_many" key="home_ou" map="" class="au"/>
<appname>open-ils.vandelay</appname>
<appname>open-ils.serial</appname>
<appname>open-ils.hold-targeter</appname>
+ <appname>open-ils.vicinity-calculator</appname>
<appname>open-ils.ebook_api</appname>
<appname>open-ils.courses</appname>
<appname>open-ils.curbside</appname>
routerLink="/staff/admin/server/config/marc_field"></eg-link-table-link>
<eg-link-table-link i18n-label label="Org Unit Proximity Adjustments"
routerLink="/staff/admin/server/actor/org_unit_proximity_adjustment"></eg-link-table-link>
+ <eg-link-table-link i18n-label label="Org Unit Shipping Hub Distances"
+ routerLink="/staff/admin/server/actor/org_unit_shipping_hub_distance"></eg-link-table-link>
<eg-link-table-link i18n-label label="Org Unit Setting Types"
routerLink="/staff/admin/server/config/org_unit_setting_type"></eg-link-table-link>
<eg-link-table-link i18n-label label="Organization Types"
import {AdminCommonModule} from '@eg/staff/admin/common.module';
import {AdminServerRoutingModule} from './routing.module';
import {AdminServerSplashComponent} from './admin-server-splash.component';
+import {OrgUnitShippingHubDistanceComponent} from './org-unit-shipping-hub-distance.component';
import {OrgUnitTypeComponent} from './org-unit-type.component';
import {PrintTemplateComponent} from './print-template.component';
import {SampleDataService} from '@eg/share/util/sample-data.service';
declarations: [
AdminServerSplashComponent,
OrgUnitTypeComponent,
+ OrgUnitShippingHubDistanceComponent,
PrintTemplateComponent,
PermGroupTreeComponent,
PermGroupMapDialogComponent
--- /dev/null
+import {Component, OnInit} from '@angular/core';
+import {ActivatedRoute} from '@angular/router';
+import {IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+
+/**
+ * Generic IDL class editor page.
+ */
+
+@Component({
+ template: `
+ <eg-title i18n-prefix prefix="{{classLabel}} Administration">
+ </eg-title>
+ <eg-staff-banner bannerText="{{classLabel}} Configuration" i18n-bannerText>
+ </eg-staff-banner>
+ <div class="alert alert-info" i18n>
+ Entries in this table contain the distance in kilometers between shipping hub locations. If configured for your service, driving distance will be used instead of as-the-crow-flies. Shipping hubs are configured from the Orginizational Units page. These numbers are used for sorting hold targets during inter-library lending. Entries can be created manually or calculated using your geolocation service. <b>Recalculating the distances will clear out any existing data.</b>.
+ </div>
+ <button [disabled]="calculating" class="btn btn-outline-dark" (click)="calculateDistances()">Calculate with Geolocation Service</button>
+ <eg-progress-inline *ngIf="calculating" ></eg-progress-inline>
+ <br>
+ <br>
+ <eg-admin-page persistKeyPfx="{{persistKeyPfx}}" idlClass="{{idlClass}}"
+ configLinkBasePath="{{configLinkBasePath}}"
+ readonlyFields="{{readonlyFields}}"
+ [disableOrgFilter]="disableOrgFilter"></eg-admin-page>
+ `
+})
+
+export class OrgUnitShippingHubDistanceComponent implements OnInit {
+
+ idlClass: string;
+ classLabel: string;
+ persistKeyPfx: string;
+ readonlyFields = '';
+ configLinkBasePath = '/staff/admin';
+
+ // API is currently calculating
+ calculating : boolean;
+ // Tell the admin page to disable and hide the automagic org unit filter
+ disableOrgFilter: boolean;
+
+ constructor(
+ private route: ActivatedRoute,
+ private idl: IdlService,
+ private net: NetService,
+ private auth: AuthService
+ ) {
+ }
+
+ ngOnInit() {
+ let schema = this.route.snapshot.paramMap.get('schema');
+ if (!schema) {
+ // Allow callers to pass the schema via static route data
+ const data = this.route.snapshot.data[0];
+ if (data) { schema = data.schema; }
+ }
+ let table = this.route.snapshot.paramMap.get('table');
+ if (!table) {
+ const data = this.route.snapshot.data[0];
+ if (data) { table = data.table; }
+ }
+ const fullTable = schema + '.' + table;
+
+ // Set the prefix to "server", "local", "workstation",
+ // extracted from the URL path.
+ // For admin pages that use none of these, avoid setting
+ // the prefix because that will cause it to double-up.
+ // e.g. eg.grid.acq.acq.cancel_reason
+ this.persistKeyPfx = this.route.snapshot.parent.url[0].path;
+ const selfPrefixers = ['acq', 'booking'];
+ if (selfPrefixers.indexOf(this.persistKeyPfx) > -1) {
+ // ACQ is a special case, because unlike 'server', 'local',
+ // 'workstation', the schema ('acq') is the root of the path.
+ this.persistKeyPfx = '';
+ } else {
+ this.configLinkBasePath += '/' + this.persistKeyPfx;
+ }
+
+ // Pass the readonlyFields param if available
+ if (this.route.snapshot.data && this.route.snapshot.data[0]) {
+ // snapshot.data is a HASH.
+ const data = this.route.snapshot.data[0];
+
+ if (data.readonlyFields) {
+ this.readonlyFields = data.readonlyFields;
+ }
+
+ if (data.disableOrgFilter) {
+ this.disableOrgFilter = true;
+ }
+ }
+
+ Object.keys(this.idl.classes).forEach(class_ => {
+ const classDef = this.idl.classes[class_];
+ if (classDef.table === fullTable) {
+ this.idlClass = class_;
+ this.classLabel = classDef.label;
+ }
+ });
+
+ if (!this.idlClass) {
+ throw new Error('Unable to find IDL class for table ' + fullTable);
+ }
+ this.calculating = false;
+ }
+
+ calculateDistances(){
+ this.calculating = true;
+ this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.geo.build_distance_matrix',
+ this.auth.token()
+ ).subscribe(
+ n => {this.calculating = false; location.reload();},
+ err => {alert('API failed to calculate ' + err);this.calculating = false;}
+ );
+ }
+}
+
+
import {RouterModule, Routes} from '@angular/router';
import {AdminServerSplashComponent} from './admin-server-splash.component';
import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
+import {OrgUnitShippingHubDistanceComponent} from './org-unit-shipping-hub-distance.component';
import {OrgUnitTypeComponent} from './org-unit-type.component';
import {PrintTemplateComponent} from './print-template.component';
import {PermGroupTreeComponent} from './perm-group-tree.component';
data: [{schema: 'actor',
table: 'org_unit_proximity_adjustment', disableOrgFilter: true}]
}, {
+ path: 'actor/org_unit_shipping_hub_distance',
+ component: OrgUnitShippingHubDistanceComponent,
+ data: [{schema: 'actor',
+ table: 'org_unit_shipping_hub_distance', readonlyFields: 'id'}]
+}, {
path: 'asset/call_number_prefix',
component: BasicAdminPageComponent,
data: [{schema: 'asset',
export CPAN_MODULES = \
Geo::Coder::Google \
+ Geo::Coder::Bing \
Business::OnlinePayment::PayPal \
String::KeyboardDistance \
Text::Levenshtein::Damerau::XS \
export CPAN_MODULES = \
Geo::Coder::Google \
+ Geo::Coder::Bing \
Business::OnlinePayment::PayPal \
String::KeyboardDistance \
Text::Levenshtein::Damerau::XS \
export CPAN_MODULES = \
Geo::Coder::Google \
+ Geo::Coder::Bing \
Business::OnlinePayment::PayPal \
String::KeyboardDistance \
Text::Levenshtein::Damerau::XS \
export CPAN_MODULES = \
Geo::Coder::OSM \
Geo::Coder::Google \
+ Geo::Coder::Bing \
Excel::Writer::XLSX \
String::KeyboardDistance \
Text::Levenshtein::Damerau::XS \
export CPAN_MODULES = \
Geo::Coder::Google \
+ Geo::Coder::Bing \
Business::OnlinePayment::PayPal \
Email::Send \
MARC::Charset \
export CPAN_MODULES = \
Geo::Coder::Google \
+ Geo::Coder::Bing \
Business::OnlinePayment::PayPal \
Email::Send \
MARC::Charset \
'File::Spec' => '0',
'File::stat' => '0',
'File::Temp' => '0',
+ 'Geo::Coder::Bing' => '0',
'Getopt::Long' => '0',
'IO::Scalar' => '0',
'List::Util' => '0',
'Unicode::Normalize' => '0',
'UNIVERSAL::require' => '0',
'UUID::Tiny' => '0',
+ 'WWW::REST' => '0',
'XML::LibXML' => '0',
'XML::LibXML::XPathContext' => '0',
'XML::LibXSLT' => '0',
lib/OpenILS/Utils/OfflineStore.pm
lib/OpenILS/Utils/Penalty.pm
lib/OpenILS/Utils/PermitHold.pm
+lib/OpenILS/Utils/VicinityCalculator.pm
lib/OpenILS/Utils/RemoteAccount.pm
lib/OpenILS/Utils/ZClient.pm
lib/OpenILS/WWW/AddedContent.pm
}
__PACKAGE__->register_method(
+ method => "build_distance_matrix",
+ api_name => "open-ils.actor.geo.build_distance_matrix",
+ signature => {
+ params => [
+ {desc => 'Authentication token', type => 'string' }
+ ]
+ }
+);
+
+sub build_distance_matrix {
+ my( $self, $client, $auth) = @_;
+ my $e = new_editor(authtoken=>$auth);
+ return $e->event unless $e->checkauth;
+
+ return $apputils->simplereq(
+ "open-ils.geo",
+ "open-ils.geo.build_distance_matrix",
+ $auth );
+}
+
+__PACKAGE__->register_method(
method => "get_my_org_ancestor_at_depth",
api_name => "open-ils.actor.org_unit.ancestor_at_depth.retrieve"
);
use OpenSRF::AppSession;
use OpenILS::Application;
use base qw/OpenILS::Application/;
+use List::MoreUtils qw(natatime);
+use List::Util qw(min);
use OpenSRF::Utils::SettingsClient;
use OpenILS::Utils::CStoreEditor qw/:funcs/;
use OpenILS::Utils::Fieldmapper;
use OpenSRF::Utils::Cache;
+use OpenILS::Utils::VicinityCalculator;
use OpenILS::Application::AppUtils;
+use Data::Dumper;
+use JSON::XS;
my $U = "OpenILS::Application::AppUtils";
use OpenSRF::Utils::Logger qw/$logger/;
};
use Geo::Coder::OSM;
use Geo::Coder::Google;
+use Geo::Coder::Bing;
use Math::Trig qw(great_circle_distance deg2rad);
use Digest::SHA qw(sha256_base64);
$cache = OpenSRF::Utils::Cache->new('global');
}
+sub calculate_driving_distance {
+ my ($self, $conn, $auth, $pointA, $pointB) = @_;
+
+ return new OpenILS::Event("BAD_PARAMS", "desc" => "Missing coordinates") unless $pointA;
+ return new OpenILS::Event("BAD_PARAMS", "desc" => "Missing coordinates") unless $pointB;
+ return new OpenILS::Event("BAD_PARAMS", "desc" => "Malformed coordinates") unless scalar(@{ $pointA }) == 2;
+ return new OpenILS::Event("BAD_PARAMS", "desc" => "Malformed coordinates") unless scalar(@{ $pointB }) == 2;
+
+ my $e = new_editor(xact => 1, authtoken=>$auth);
+ return $e->die_event unless $e->checkauth;
+ # get the requestor's org unit
+ my $org = $e->requestor->ws_ou;
+ my $use_geo = $e->retrieve_config_global_flag('opac.use_geolocation');
+ $use_geo = ($use_geo and $U->is_true($use_geo->enabled));
+ return new OpenILS::Event("GEOCODING_NOT_ENABLED") unless ($U->is_true($use_geo));
+
+ return new OpenILS::Event("BAD_PARAMS", "desc" => "No org ID supplied") unless $org;
+ my $service_id = $U->ou_ancestor_setting_value($org, 'opac.geographic_location_service_for_address');
+ return new OpenILS::Event("GEOCODING_NOT_ALLOWED") unless ($U->is_true($service_id));
+
+ my $service = $e->retrieve_config_geolocation_service($service_id);
+ return new OpenILS::Event("GEOCODING_NOT_ALLOWED") unless ($U->is_true($service));
+
+ my $geo_coder = _create_geocoder($service);
+ if (!$geo_coder) {
+ return OpenILS::Event->new('GEOCODING_LOCATION_NOT_FOUND');
+ }
+
+ if ($service->service_code eq 'Bing') {
+ my $origin_coord = join(',',@{ $pointA });
+ my $dest_coord = join(',',@{ $pointB });
+ my $uri = URI->new("https://dev.virtualearth.net/REST/v1/Routes/DistanceMatrix?origins=$origin_coord&destinations=$dest_coord&distanceUnit=km&travelMode=driving&key=".$service->api_key);
+ my $results = $geo_coder->_rest_request($uri)->{results};
+ return $results->[0]->{travelDistance};
+ } else {
+ $logger->info($service->service_code." can not get driving distance. Reverting to as-the-crow-flies.");
+ # if geocoder can't do driving distance just get as-the-crow-flies
+ return calculate_distance($self, $conn, $pointA, $pointB);
+ }
+ return 0;
+}
+
+__PACKAGE__->register_method(
+ method => "calculate_driving_distance",
+ api_name => "open-ils.geo.calculate_driving_distance",
+ signature => {
+ params => [
+ {type => 'string', desc => 'User\'s authorization token'},
+ {type => 'array', desc => 'An array containing latitude and longitude for point A'},
+ {type => 'array', desc => 'An array containing latitude and longitude for point B'}
+ ],
+ return => { desc => 'Driving distance between points A and B in kilometers'}
+ }
+);
+
sub calculate_distance {
my ($self, $conn, $pointA, $pointB) = @_;
}
);
+sub _post_request {
+ my ($bing, $uri, $form, $json_coder) = @_;
+ my $json = $json_coder->encode($form);
+ return unless $uri;
+ #$logger->info($uri);
+ #$logger->info($form);
+ my $res = $bing->{response} = $bing->ua->post($uri,'Content-Length' => 3500,'Content-Type' => 'application/json',Content => $json);
+ unless($res->is_success){
+ $logger->error("API ERROR\n");
+ my @error = split /\n/, $res->decoded_content;
+ foreach(@error){
+ $logger->error($_);
+ }
+ return;
+ }
+ # Change the content type of the response from 'application/json' so
+ # HTTP::Message will decode the character encoding.
+ $res->content_type('text/plain');
+
+ my $content = $res->decoded_content;
+ return unless $content;
+ my $data= eval { $json_coder->decode($res->decoded_content) };
+ return unless $data;
+ my @results = @{ $data->{resourceSets}[0]{resources} || [] };
+ return wantarray ? @results : $results[0];
+}
+
+sub calculate_bulk_driving_distance {
+ my ($self, $conn, $auth, $origin_array, $destination_array) = @_;
+
+ return new OpenILS::Event("BAD_PARAMS", "desc" => "Missing coordinates") unless $origin_array;
+ return new OpenILS::Event("BAD_PARAMS", "desc" => "Missing coordinates") unless $destination_array;
+
+ my $e = new_editor(xact => 1, authtoken=>$auth);
+ return $e->die_event unless $e->checkauth;
+ # get the requestor's org unit
+ my $org = $e->requestor->ws_ou;
+ my $use_geo = $e->retrieve_config_global_flag('opac.use_geolocation');
+ $use_geo = ($use_geo and $U->is_true($use_geo->enabled));
+ return new OpenILS::Event("GEOCODING_NOT_ENABLED") unless ($U->is_true($use_geo));
+
+ return new OpenILS::Event("BAD_PARAMS", "desc" => "No org ID supplied") unless $org;
+ my $service_id = $U->ou_ancestor_setting_value($org, 'opac.geographic_location_service_for_address');
+ return new OpenILS::Event("GEOCODING_NOT_ALLOWED") unless ($U->is_true($service_id));
+
+ my $service = $e->retrieve_config_geolocation_service($service_id);
+ return new OpenILS::Event("GEOCODING_NOT_ALLOWED") unless ($U->is_true($service));
+
+ my $geo_coder = _create_geocoder($service);
+ if (!$geo_coder) {
+ return OpenILS::Event->new('GEOCODING_LOCATION_NOT_FOUND');
+ }
+
+ my @results;
+
+ if ($service->service_code eq 'Bing') {
+ my @origins;
+ my @destinations;
+ my $uri = URI->new("https://dev.virtualearth.net/REST/v1/Routes/DistanceMatrix?key=".$service->api_key);
+
+ # get the data into the right form for our request
+ foreach(@{$origin_array}){
+ my %ocoord;
+ $ocoord{'latitude'} = $_->[0];
+ $ocoord{'longitude'} = $_->[1];
+ push(@origins, \%ocoord);
+ }
+ $logger->info(Dumper(\@origins));
+
+ foreach(@{$destination_array}){
+ my %dcoord;
+ $dcoord{'latitude'} = $_->[0];
+ $dcoord{'longitude'} = $_->[1];
+ push(@destinations, \%dcoord);
+ }
+
+ # find out how many coords we can process per request.
+ my $budget = int(2500 / scalar(@origins));
+ $logger->debug("data being chunked into ".$budget.".\n");
+ my $it = natatime $budget, @origins;
+ my $real_index = 0;
+ my $json_coder = JSON::XS->new->convert_blessed;
+
+ while (my @coords = $it->())
+ {
+ my %content = (
+ origins => \@coords,
+ destinations => \@destinations,
+ travelMode => "driving",
+ timeUnit => "minute",
+ distanceUnit => "km"
+ );
+ $logger->info("Hash for JSON: ".Dumper(\%content));
+ my $rest_req = _post_request($geo_coder,$uri,\%content,$json_coder);
+
+ #calculate the distance matrix for this chunk of origins.
+ $logger->info("results from post: ".Dumper($rest_req));
+ my @distance_matrix = eval{$rest_req->{results}};
+ if(@distance_matrix){
+ for my $ref (@distance_matrix) {
+ for (@$ref){
+ my %dist;
+ $dist{origin} = $_->{originIndex} + $real_index;
+ $dist{destination} = $_->{destinationIndex};
+ $dist{distance} = $_->{travelDistance};
+ push(@results,\%dist);
+ }
+ }
+
+ $logger->info("Information from server: ".Dumper(\@results));
+ $real_index += min($budget,scalar(@coords));
+ print("setting index to ".$real_index."\n");
+ }
+ }
+
+ return \@results;
+ } else {
+ $logger->info($service->service_code." can not get driving distance. Reverting to as-the-crow-flies.");
+ # if geocoder can't do driving distance just get as-the-crow-flies
+ my $index = 0;
+ foreach(@{$origin_array}){
+ my $pointA = $_;
+ my $dindex = 0;
+ foreach(@{$destination_array}){
+ my $pointB = $_;
+ my $d = calculate_distance($self, $conn, $pointA, $pointB);
+ my %dist;
+ $dist{origin} = $index;
+ $dist{destination} = $dindex;
+ $dist{distance} = $d;
+ push(@results,\%dist);
+ $dindex++;
+ }
+ $index++;
+ }
+
+ return \@results;
+ }
+ return \@results;
+}
+
+__PACKAGE__->register_method(
+ method => "calculate_bulk_driving_distance",
+ api_name => "open-ils.geo.calculate_bulk_driving_distance",
+ signature => {
+ params => [
+ {type => 'string', desc => 'User\'s authorization token'},
+ {type => 'array', desc => 'An array containing latitude and longitude origin points as an array.'},
+ {type => 'array', desc => 'An array containing latitude and longitude destination points as an array.'}
+ ],
+ return => { desc => 'Driving distance between origin points and destinations in kilometers'}
+ }
+);
+
+__PACKAGE__->register_method(
+ method => 'build_distance_matrix',
+ api_name => 'open-ils.geo.build_distance_matrix',
+ signature => {
+ desc => q/Batch calculation of shipping hub distance matrix./,
+ return => {desc => 'See API Options for return types'}
+ }
+);
+
+sub build_distance_matrix{
+ my ($self, $conn, $auth) = @_;
+ my $calculator = OpenILS::Utils::VicinityCalculator->new($auth);
+ $calculator->calculate_distance_matrix();
+ return 1;
+}
+
+
+__PACKAGE__->register_method(
+ method => 'get_all_hubs',
+ api_name => 'open-ils.geo.shipping-hubs.retrieve',
+ signature => {
+ desc => q/Retrieve a list of all shipping hubs/,
+ }
+);
+
+sub get_all_hubs{
+ my ($self, $conn, $auth) = @_;
+ my $calculator = OpenILS::Utils::VicinityCalculator->new();
+ $logger->info("retreiving org unit shipping hubs");
+ return $calculator->get_all_hubs();
+}
+
+__PACKAGE__->register_method(
+ method => 'get_hub_from_ou',
+ api_name => 'open-ils.geo.shipping-hub.retrieve',
+ signature => {
+ desc => q/Retrieve a shipping hub from a given OU/,
+ }
+);
+
+sub get_hub_from_ou{
+ my ($self, $org_unit) = @_;
+ my $calculator = OpenILS::Utils::VicinityCalculator::Matrix->new();
+ $logger->info("retreiving org unit shipping hubs");
+ return $calculator->get_hub_from_ou($org_unit);
+}
+
+__PACKAGE__->register_method(
+ method => 'get_distance_between_shipping_hubs',
+ api_name => 'open-ils.geo.shipping-hubs.distance',
+ signature => {
+ desc => q/Retrieve the distance between two shipping hubs/,
+ }
+);
+
+sub get_distance_between_shipping_hubs {
+ my ($self, $origin_hub, $dest_hub) = @_;
+ my $calculator = OpenILS::Utils::VicinityCalculator::Matrix->new();
+ $logger->info("calculating org unit shipping hub distances");
+ return $calculator->distance_between_hubs($origin_hub,$dest_hub);
+}
+
sub sort_orgs_by_distance_from_coordinate {
my ($self, $conn, $pointA, $orgs) = @_;
}
);
+# creates a Geo::Coder object
+sub _create_geocoder {
+ my $service = shift;
+ my $service_id = $service->id;
+ my $geo_coder;
+ eval {
+ if ($service->service_code eq 'Free') {
+ if ($have_geocoder_free) {
+ $logger->debug("Using Geo::Coder::Free (service id $service_id)");
+ $geo_coder = Geo::Coder::Free->new();
+ } else {
+ $logger->error("geosort: Geo::Coder::Free not installed but referenced.");
+ }
+ } elsif ($service->service_code eq 'Google') {
+ $logger->debug("Using Geo::Coder::Google (service id $service_id)");
+ $geo_coder = Geo::Coder::Google->new(key => $service->api_key);
+ } elsif ($service->service_code eq 'Bing') {
+ $logger->debug("Using Geo::Coder::Bing (service id $service_id)");
+ $geo_coder = Geo::Coder::Bing->new(key => $service->api_key);
+ } else {
+ $logger->debug("Using Geo::Coder::OSM (service id $service_id)");
+ $geo_coder = Geo::Coder::OSM->new();
+ }
+ };
+ if ($@ || !$geo_coder) {
+ $logger->error("geosort: problem creating Geo::Coder instance : $@");
+ }
+ return $geo_coder;
+}
sub retrieve_coordinates { # invoke 3rd party API for latitude/longitude lookup
my ($self, $conn, $org, $address) = @_;
my $coords = OpenSRF::Utils::JSON->JSON2perl($cache->get_cache($cache_key));
return $coords if $coords;
- my $geo_coder;
- eval {
- if ($service->service_code eq 'Free') {
- if ($have_geocoder_free) {
- $logger->debug("Using Geo::Coder::Free (service id $service_id)");
- $geo_coder = Geo::Coder::Free->new();
- } else {
- $logger->error("geosort: Geo::Coder::Free not installed but referenced.");
- return OpenILS::Event->new('GEOCODING_LOCATION_NOT_FOUND');
- }
- } elsif ($service->service_code eq 'Google') {
- $logger->debug("Using Geo::Coder::Google (service id $service_id)");
- $geo_coder = Geo::Coder::Google->new(key => $service->api_key);
- } else {
- $logger->debug("Using Geo::Coder::OSM (service id $service_id)");
- $geo_coder = Geo::Coder::OSM->new();
- }
- };
- if ($@ || !$geo_coder) {
- $logger->error("geosort: problem creating Geo::Coder instance : $@");
+ my $geo_coder = _create_geocoder($service);
+ if (!$geo_coder) {
return OpenILS::Event->new('GEOCODING_LOCATION_NOT_FOUND');
}
my $location;
} elsif ($service->service_code eq 'Google') {
$latitude = $location->{'geometry'}->{'location'}->{'lat'};
$longitude = $location->{'geometry'}->{'location'}->{'lng'};
+ } elsif ($service->service_code eq 'Bing') {
+ $latitude = $location->{point}{coordinates}[0];
+ $longitude = $location->{point}{coordinates}[1];
} else {
$latitude = $location->{lat};
$longitude = $location->{lon};
#-------------------------------------------------------------------------------
+package actor::org_unit_shipping_hub;
+use base qw/actor/;
+
+__PACKAGE__->table( 'actor_org_unit_shipping_hub' );
+__PACKAGE__->columns( Primary => qw/id/);
+__PACKAGE__->columns( Essential => qw/org_unit hub/);
+
+
+#-------------------------------------------------------------------------------
+package actor::org_unit_shipping_hub_distance;
+use base qw/actor/;
+
+__PACKAGE__->table( 'actor_org_unit_shipping_hub_distance' );
+__PACKAGE__->columns( Primary => qw/id/);
+__PACKAGE__->columns( Essential => qw/orig_hub dest_hub distance/);
+
+
+#-------------------------------------------------------------------------------
package actor::stat_cat;
use base qw/actor/;
actor::org_unit::closed_date->sequence( 'actor.org_unit_closed_id_seq' );
#---------------------------------------------------------------------
+ package actor::org_unit_shipping_hub;
+
+ actor::org_unit_shipping_hub->table( 'actor.org_unit_shipping_hub' );
+ actor::org_unit_shipping_hub->sequence( 'actor.org_unit_shipping_hub_id_seq' );
+
+ #---------------------------------------------------------------------
package actor::org_unit_setting;
actor::org_unit_setting->table( 'actor.org_unit_setting' );
use strict;
use warnings;
use DateTime;
+use Data::Dumper;
use OpenSRF::AppSession;
use OpenSRF::Utils::Logger qw(:logger);
use OpenSRF::Utils::JSON;
use strict;
use warnings;
use DateTime;
+use Data::Dumper;
+use OpenILS::Utils::VicinityCalculator;
use OpenSRF::AppSession;
use OpenILS::Utils::DateTime qw/:datetime/;
use OpenSRF::Utils::Logger qw(:logger);
use OpenILS::Application::AppUtils;
use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use List::Util qw(shuffle max);
sub new {
my ($class, %args) = @_;
return $self->{hold};
}
+# return the number by which shipping distances are divided
+# during hold copy scoring. Defaults to 10.
+sub distance_divisor {
+ my ($self) = @_;
+ if (!defined($self->{distance_divisor}) || !$self->{distance_divisor}) {
+ my $dd = $self->editor->search_config_global_flag({
+ name => 'circ.holds.distance_divisor',
+ enabled => 't'
+ })->[0];
+
+ # If no flag is present, default to 10.0
+ $self->{distance_divisor} = $dd ? $dd->value : 10.0;
+ }
+ return $self->{distance_divisor};
+}
+
sub inside_hard_stall_interval {
my ($self) = @_;
if (defined $self->{inside_hard_stall_interval}) {
# Collect copy proximity info (generated via DB trigger)
# from our newly create copy maps.
my $hold_copy_maps = $self->editor->json_query({
- select => {ahcm => ['target_copy', 'proximity']},
+ select => {ahcm => ['target_copy', 'proximity','shipping_distance']},
from => 'ahcm',
where => {hold => $self->hold_id}
});
my %copy_prox_map =
map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
+
+ my %copy_dist_map =
+ map {$_->{target_copy} => $_->{shipping_distance}} @$hold_copy_maps;
# Pre-fetch the org setting value for all circ libs so that
# later calls can reference the cached value.
my %prox_map;
for my $copy_hash (@{$self->copies}) {
- my $prox = $copy_prox_map{$copy_hash->{id}};
+ my $copy_id = $copy_hash->{id};
+ my $prox = $copy_prox_map{$copy_id};
+ my $shipping_distance = $copy_dist_map{$copy_id};
$copy_hash->{proximity} = $prox;
+ $copy_hash->{shipping_distance} = $shipping_distance;
$prox_map{$prox} ||= [];
my $weight = $self->parent->get_ou_setting(
return undef;
}
-# Returns the closest copy by proximity that is a confirmed valid
+# Returns the closest copy by proximity and shipping distance that is a confirmed valid
# targetable copy.
sub find_nearest_copy {
my $self = shift;
my %prox_map = %{$self->{weighted_prox_map}};
my $hold = $self->hold;
+ my $req_hub;
+ my $geo_sort = $self->editor->retrieve_config_global_flag('opac.use_geolocation');
+ my $geo_sort_for_holds = $self->parent->get_ou_setting(
+ $hold->pickup_lib,
+ 'circ.holds.target_sort_by_geographic_proximity', $self->editor);
+ my $do_geosort = $geo_sort && $U->is_true($geo_sort_for_holds);
my %seen;
# See if there are in-use (targeted) copies "here".
# copy is found that is suitable for targeting.
my $no_copies = 1;
for my $prox (sort {$a <=> $b} keys %prox_map) {
- my @copies = @{$prox_map{$prox}};
+ my @copies = shuffle(@{$prox_map{$prox}});
next unless @copies;
$no_copies = 0;
);
last if ($prox > 0); # No point in looking further "out".
}
+
+
+ if($do_geosort){
+ # sort all of the copies by shipping hub distance.
+ # two copies with equal shipping distance are sorted at random.
+ # pick copy with the lowest score
+ for my $c (sort { $self->score_copy($a) <=> $self->score_copy($b) } @copies){
+ next if $seen{$c->{id}};
+
+ return $c if $self->copy_is_permitted($c);
+ $seen{$c->{id}} = 1;
+
+ last unless(@copies);
+ }
+ }
+ else{
+ # Pick a copy at random from each tier of the proximity map,
+ # starting at the lowest proximity and working up, until a
+ # copy is found that is suitable for targeting.
+ my $rand = int(rand(scalar(@copies)));
- my $rand = int(rand(scalar(@copies)));
+ while (my ($c) = splice(@copies, $rand, 1)) {
+ $rand = int(rand(scalar(@copies)));
+ next if $seen{$c->{id}};
- while (my ($c) = splice(@copies, $rand, 1)) {
- $rand = int(rand(scalar(@copies)));
- next if $seen{$c->{id}};
+ return $c if $self->copy_is_permitted($c);
+ $seen{$c->{id}} = 1;
- return $c if $self->copy_is_permitted($c);
- $seen{$c->{id}} = 1;
+ last unless(@copies);
- last unless(@copies);
+ }
}
}
return undef;
}
+# calculates a value for the copy based on the shipping distance
+# shipping distance is divided by the distance_divisor and
+# decimal part is removed. This provides for cases where
+# shipping distance between two branches are negiligibly far apart.
+# golf rules, larger scores make less valuable targets.
+sub score_copy {
+ my ($self, $copy) = @_;
+ return 0 unless $copy;
+ my $distance_divisor = $self->distance_divisor();
+ my $shipping_distance = $copy->{shipping_distance};
+ return int($shipping_distance/$distance_divisor);
+}
+
# Returns true if the provided copy passes the hold permit test for our
# hold and can be used for targeting.
# When a copy fails the test, it is removed from $self->copies.
--- /dev/null
+package OpenILS::Utils::VicinityCalculator;
+use strict; use warnings;
+use Geo::Coder::Bing;
+use JSON;
+use Data::Dumper;
+use URI;
+use OpenSRF::System;
+use OpenILS::Application::Actor;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::AppSession;
+use OpenILS::Utils::Fieldmapper;
+use OpenSRF::Utils::SettingsClient;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+
+our $U = "OpenILS::Application::AppUtils";
+my $actor;
+
+sub new {
+ my ($class, $auth) = @_;
+ my $self = {
+ editor => new_editor(authtoken => $auth),
+ auth => $auth,
+ hub_cache => {},
+ coord_cache => {},
+
+ };
+ $self->{editor}->init;
+ return bless($self, $class);
+}
+
+sub uniq {
+ my %seen;
+ grep !$seen{$_}++, @_;
+}
+
+# Use Bing maps API to calculate the distances between all shipping hubs
+sub calculate_distance_matrix {
+ my $self = shift;
+ # find hubs for all OUs
+ my @hubs = $self->get_all_hubs();
+ # find addresses of all hub OUs
+ $logger->info("Getting shipping hub addresses");
+ my %hub_coord = $self->get_coord_from_ou(uniq(@hubs));
+ my @origins = values(%hub_coord);
+ my @destinations = values(%hub_coord);
+ my @hub_ids = keys(%hub_coord);
+ # make one giant request to our geolocation service to calculate our distance matrix
+ my $geo = OpenSRF::AppSession->create('open-ils.geo');
+ my $geo_request = $geo->request('open-ils.geo.calculate_bulk_driving_distance',
+ $self->{auth}, \@origins, \@destinations);
+ my $result = $geo_request->recv();
+ my $content = $result->content();
+ $logger->info("content from API: ".Dumper($content));
+ my @distance_matrix = @{$content};
+ if(@distance_matrix){
+ $self->{editor}->xact_begin;
+ # clear out existing matrix
+ $logger->info("Old distance matrix is being cleared out.");
+ $self->clear_hub_distances();
+ foreach(@distance_matrix) {
+ print(Dumper $_);
+ # create our AOUSHD objects for the data returned
+ my $dist = Fieldmapper::actor::org_unit_shipping_hub_distance->new;
+ $dist->orig_hub($hub_ids[$_->{origin}]);
+ $dist->dest_hub($hub_ids[$_->{destination}]);
+ $dist->distance($_->{distance});
+ # place AOUSHD into the DB
+ $self->{editor}->runmethod('create', 'actor.org_unit_shipping_hub_distance', 'aoushd', $dist);
+
+ }
+ # commit to DB
+ $self->{editor}->xact_commit;
+ }
+ else{
+ $logger->error("API failed to calculate distance matrix");
+ }
+}
+
+sub get_coord_from_ou {
+ my($self,@org_ids) = @_;
+ my @ma = $self->{editor}->json_query({
+ select => {
+ 'aoa' => ['org_unit','latitude','longitude','address_type']
+ },
+ from => {'aou' => {'aoa' => {'field' => 'id', 'fkey' => 'mailing_address'}}},
+ where => {'id' => [@org_ids]}
+ });
+ my %coords;
+
+ for my $ref (@ma) {
+ for (@$ref){
+ $coords{$_->{org_unit}} = [$_->{latitude}, $_->{longitude}];
+ }
+ }
+ return %coords;
+}
+
+# remove all existing distance calculations.
+# TODO make this all happen in one query
+# what could the analog to DELETE FROM TABLE be?
+sub clear_hub_distances {
+ my($self,@org_ids) = @_;
+ my @ma = $self->{editor}->json_query({
+ select => {
+ aoushd => [
+ {
+ column => 'id',
+ }
+ ]
+ },
+ from => 'aoushd'
+ });
+
+ for my $ref (@ma) {
+ for (@$ref){
+ my $dist = Fieldmapper::actor::org_unit_shipping_hub_distance->new;
+ $dist->id($_->{id});
+ $self->{editor}->runmethod('delete', 'actor.org_unit_shipping_hub_distance', 'aoushd', $dist);
+ }
+ }
+}
+
+sub get_all_hubs {
+my($self) = @_;
+my @sh = $self->{editor}->json_query({
+ select => {
+ aou => ['shipping_hub_ou'],
+ },
+ from => 'aou'
+ });
+ my @hubs;
+ for my $ref (@sh) {
+ for (@$ref){
+ my $hub = $_->{shipping_hub_ou};
+ if($hub && $hub != 0 && !($hub eq '')){
+ push @hubs, $hub;
+ }
+ }
+ }
+ return @hubs;
+}
+
+package OpenILS::Utils::VicinityCalculator::Matrix;
+use OpenSRF::System;
+use OpenILS::Application::Actor;
+use OpenSRF::Utils::Logger qw(:logger);
+use OpenSRF::AppSession;
+use OpenILS::Utils::Fieldmapper;
+use OpenSRF::Utils::SettingsClient;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use Data::Dumper;
+
+our $U = "OpenILS::Application::AppUtils";
+sub new {
+ my ($class) = @_;
+ my $self = { editor => new_editor() };
+ $self->{editor}->init;
+ return bless($self, $class);
+}
+
+sub hub_matrix {
+ my ($self, $origin_hub, $dest_hubs_ref) = @_;
+ my @dest_hubs = @{$dest_hubs_ref};
+ my @d = $self->{editor}->json_query({
+ select => {'aoushd' => [{column => 'dest_hub'},{column => 'distance'}]},
+ from => 'aoushd',
+ where => {'orig_hub'=>[$origin_hub],'dest_hub'=>[@dest_hubs]},
+ order_by => [
+ {class => 'aoushd', field => 'distance', direction => 'ASC'},
+ ]
+ });
+
+ my %matrix;
+ for my $ref (@d) {
+ for (@$ref){
+ $matrix{$_->{'dest_hub'}}=$_->{distance};
+ }
+ }
+ # hub matrix will be undefined if any destination hubs are missing from the return list.
+ for my $hub (@dest_hubs){
+ next if $matrix{$hub};
+ $logger->error("OU $origin_hub has no calculation to OU $hub. open-ils.geo.build-distance-matrix must be run before vicinity based hold targeting can continue!");
+ return undef;
+ }
+ return %matrix;
+}
+
+sub distance_between_hubs {
+ my ($self, $origin_hub, $dest_hub) = @_;
+ my @d = $self->{editor}->json_query({
+ select => {'aoushd' => [{column => 'distance'}]},
+ from => 'aoushd',
+ where => {'orig_hub'=>[$origin_hub],'dest_hub'=>[$dest_hub]}
+ });
+ for my $ref (@d) {
+ for (@$ref){
+ return $_->{distance};
+ }
+ }
+ $logger->error("OU $origin_hub has no calculation to OU $dest_hub. open-ils.geo.build-distance-matrix must be run!");
+ return undef;
+}
+
+sub get_target_hubs{
+ my $self = shift;
+ my $copies_ref = shift;
+ my @target_copies = @{ $copies_ref };
+ my @h = $self->{editor}->json_query({
+ select => {'acp' => ['id','circ_lib']},
+ from => 'acp',
+ where => {'+acp'=>{id => [@target_copies]}}
+ });
+ my %circ_libs;
+ for my $ref (@h) {
+ for (@$ref){
+ $circ_libs{$_->{id}} = $_->{circ_lib};
+ }
+ }
+
+ my %circ_hubs;
+ my %hubs;
+ my @sh = $self->{editor}->json_query({
+ select => [{column=>'org_unit'},{column=>'hub'}],
+ from => [
+ 'actor.list_org_unit_ancestor_shipping_hub',values(%circ_libs)]
+ });
+ for my $ref (@sh) {
+ for (@$ref){
+ $circ_hubs{$_->{org_unit}} = $_->{hub};
+ }
+ }
+ foreach my $copy(@target_copies){
+ $hubs{$copy} = $circ_hubs{$circ_libs{$copy}};
+ }
+
+ return %hubs;
+}
+
+sub get_hub_from_ou {
+my($self,@org_ids) = @_;
+my @sh = $self->{editor}->json_query({
+ select => [{column=>'org_unit'},{column=>'hub'}],
+ from => [
+ 'actor.list_org_unit_ancestor_shipping_hub',@org_ids]
+ });
+ return $sh[0][0]->{'hub'};
+}
+1;
\ No newline at end of file
);
CREATE INDEX actor_usr_privacy_waiver_usr_idx ON actor.usr_privacy_waiver (usr);
+ALTER TABLE actor.org_unit
+ADD COLUMN shipping_hub_ou BIGINT REFERENCES actor.org_unit(id) ON DELETE SET NULL;
+
+CREATE OR REPLACE FUNCTION actor.list_org_unit_ancestor_shipping_hub(VARIADIC orgs NUMERIC[]) RETURNS TABLE(org_unit INT,hub INT)
+ AS
+$func$
+DECLARE
+ rec record;
+ cur_org INT;
+ next_hub INT;
+ org_id INT;
+BEGIN
+ FOREACH org_id IN ARRAY orgs LOOP
+ cur_org := org_id;
+ org_unit := cur_org;
+ LOOP
+ SELECT INTO next_hub actor.org_unit.shipping_hub_ou FROM actor.org_unit WHERE actor.org_unit.id = cur_org;
+ IF FOUND AND next_hub IS NOT NULL THEN
+ hub := next_hub;
+ return next;
+ EXIT;
+ END IF;
+ SELECT INTO cur_org parent_ou FROM actor.org_unit WHERE actor.org_unit.id = cur_org;
+ EXIT WHEN cur_org IS NULL;
+ END LOOP;
+ END LOOP;
+ RETURN;
+END;
+$func$ LANGUAGE PLPGSQL;
+
+CREATE TABLE actor.org_unit_shipping_hub_distance (
+ id SERIAL PRIMARY KEY,
+ orig_hub BIGINT NOT NULL REFERENCES actor.org_unit(id) ON DELETE CASCADE DEFERRABLE,
+ dest_hub BIGINT NOT NULL REFERENCES actor.org_unit(id) ON DELETE CASCADE DEFERRABLE,
+ distance INT NOT NULL
+);
+
COMMIT;
hold INT NOT NULL REFERENCES action.hold_request (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
target_copy BIGINT NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
proximity NUMERIC,
+ shipping_distance NUMERIC,
CONSTRAINT copy_once_per_hold UNIQUE (hold,target_copy)
);
-- CREATE INDEX acm_hold_idx ON action.hold_copy_map (hold);
END;
$f$ LANGUAGE PLPGSQL;
+CREATE OR REPLACE FUNCTION action.hold_copy_calculated_shipping_distance(
+ ahr_id INT,
+ acp_id BIGINT
+) RETURNS NUMERIC AS $f$
+DECLARE
+ ahr action.hold_request%ROWTYPE;
+ acp asset.copy%ROWTYPE;
+
+ dist NUMERIC;
+ o_hub NUMERIC;
+ d_hub NUMERIC;
+BEGIN
+
+ SELECT * INTO ahr FROM action.hold_request WHERE id = ahr_id;
+ SELECT * INTO acp FROM asset.copy WHERE id = acp_id;
+
+
+ SELECT hub from actor.list_org_unit_ancestor_shipping_hub(ahr.pickup_lib)
+ INTO o_hub;
+ SELECT hub from actor.list_org_unit_ancestor_shipping_hub(acp.circ_lib)
+ INTO d_hub;
+ SELECT distance from actor.org_unit_shipping_hub_distance aoushd
+ where aoushd.orig_hub = o_hub and aoushd.dest_hub = d_hub
+ INTO dist;
+
+ RETURN dist;
+END;
+$f$ LANGUAGE PLPGSQL;
+
CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity_update () RETURNS TRIGGER AS $f$
BEGIN
NEW.proximity := action.hold_copy_calculated_proximity(NEW.hold,NEW.target_copy);
END;
$f$ LANGUAGE PLPGSQL;
+CREATE OR REPLACE FUNCTION action.hold_copy_calculated_shipping_distance_update () RETURNS TRIGGER AS $f$
+BEGIN
+ NEW.shipping_distance := action.hold_copy_calculated_shipping_distance(NEW.hold,NEW.target_copy);
+ RETURN NEW;
+END;
+$f$ LANGUAGE PLPGSQL;
+
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 ();
+CREATE TRIGGER hold_copy_shipping_distance_update_tgr BEFORE INSERT OR UPDATE ON action.hold_copy_map FOR EACH ROW EXECUTE PROCEDURE action.hold_copy_calculated_shipping_distance_update ();
CREATE TABLE action.usr_circ_history (
id BIGSERIAL PRIMARY KEY,
'integer'
);
+-- Hold Targeter Geosort
+
+ALTER TABLE actor.org_unit
+ADD COLUMN shipping_hub_ou BIGINT REFERENCES actor.org_unit(id) ON DELETE SET NULL;
+
+CREATE OR REPLACE FUNCTION actor.list_org_unit_ancestor_shipping_hub(VARIADIC orgs NUMERIC[]) RETURNS TABLE(org_unit INT,hub INT)
+ AS
+$func$
+DECLARE
+ rec record;
+ cur_org INT;
+ next_hub INT;
+ org_id INT;
+BEGIN
+ FOREACH org_id IN ARRAY orgs LOOP
+ cur_org := org_id;
+ org_unit := cur_org;
+ LOOP
+ SELECT INTO next_hub actor.org_unit.shipping_hub_ou FROM actor.org_unit WHERE actor.org_unit.id = cur_org;
+ IF FOUND AND next_hub IS NOT NULL THEN
+ hub := next_hub;
+ return next;
+ EXIT;
+ END IF;
+ SELECT INTO cur_org parent_ou FROM actor.org_unit WHERE actor.org_unit.id = cur_org;
+ EXIT WHEN cur_org IS NULL;
+ END LOOP;
+ END LOOP;
+ RETURN;
+END;
+$func$ LANGUAGE PLPGSQL;
+
+CREATE TABLE actor.org_unit_shipping_hub_distance (
+ id SERIAL PRIMARY KEY,
+ orig_hub BIGINT NOT NULL REFERENCES actor.org_unit(id) ON DELETE CASCADE DEFERRABLE,
+ dest_hub BIGINT NOT NULL REFERENCES actor.org_unit(id) ON DELETE CASCADE DEFERRABLE,
+ distance INT NOT NULL
+);
+
+INSERT into config.org_unit_setting_type
+( name, grp, label, description, datatype, fm_class ) VALUES
+( 'circ.holds.target_sort_by_geographic_proximity', 'holds',
+ oils_i18n_gettext('circ.holds.target_sort_by_geographic_proximity',
+ 'Use shipping hub distance based hold targeting',
+ 'coust', 'label'),
+ oils_i18n_gettext('circ.holds.target_sort_by_geographic_proximity',
+ 'Use shipping hub distance based hold targeting',
+ 'coust', 'description'),
+ 'bool', null)
+;
+
+ALTER TABLE action.hold_copy_map
+ADD COLUMN shipping_distance NUMERIC;
+
+CREATE OR REPLACE FUNCTION action.hold_copy_calculated_shipping_distance(
+ ahr_id INT,
+ acp_id BIGINT
+) RETURNS NUMERIC AS $f$
+DECLARE
+ ahr action.hold_request%ROWTYPE;
+ acp asset.copy%ROWTYPE;
+
+ dist NUMERIC;
+ o_hub NUMERIC;
+ d_hub NUMERIC;
+BEGIN
+
+ SELECT * INTO ahr FROM action.hold_request WHERE id = ahr_id;
+ SELECT * INTO acp FROM asset.copy WHERE id = acp_id;
+
+
+ SELECT hub from actor.list_org_unit_ancestor_shipping_hub(ahr.pickup_lib)
+ INTO o_hub;
+ SELECT hub from actor.list_org_unit_ancestor_shipping_hub(acp.circ_lib)
+ INTO d_hub;
+ SELECT distance from actor.org_unit_shipping_hub_distance aoushd
+ where aoushd.orig_hub = o_hub and aoushd.dest_hub = d_hub
+ INTO dist;
+
+ RETURN dist;
+END;
+$f$ LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION action.hold_copy_calculated_shipping_distance_update () RETURNS TRIGGER AS $f$
+BEGIN
+ NEW.shipping_distance := action.hold_copy_calculated_shipping_distance(NEW.hold,NEW.target_copy);
+ RETURN NEW;
+END;
+$f$ LANGUAGE PLPGSQL;
+
+CREATE TRIGGER hold_copy_shipping_distance_update_tgr BEFORE INSERT OR UPDATE ON action.hold_copy_map FOR EACH ROW EXECUTE PROCEDURE action.hold_copy_calculated_shipping_distance_update ();
+
------------------- Disabled example A/T defintions ------------------------------
-- Create a "dummy" slot when applicable, and trigger the "offer curbside" events
--- /dev/null
+BEGIN;
+
+ALTER TABLE action.hold_copy_map
+ADD COLUMN shipping_distance NUMERIC;
+
+CREATE OR REPLACE FUNCTION action.hold_copy_calculated_shipping_distance(
+ ahr_id INT,
+ acp_id BIGINT
+) RETURNS NUMERIC AS $f$
+DECLARE
+ ahr action.hold_request%ROWTYPE;
+ acp asset.copy%ROWTYPE;
+
+ dist NUMERIC;
+ o_hub NUMERIC;
+ d_hub NUMERIC;
+BEGIN
+
+ SELECT * INTO ahr FROM action.hold_request WHERE id = ahr_id;
+ SELECT * INTO acp FROM asset.copy WHERE id = acp_id;
+
+
+ SELECT hub from actor.list_org_unit_ancestor_shipping_hub(ahr.pickup_lib)
+ INTO o_hub;
+ SELECT hub from actor.list_org_unit_ancestor_shipping_hub(acp.circ_lib)
+ INTO d_hub;
+ SELECT distance from actor.org_unit_shipping_hub_distance aoushd
+ where aoushd.orig_hub = o_hub and aoushd.dest_hub = d_hub
+ INTO dist;
+
+ RETURN dist;
+END;
+$f$ LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION action.hold_copy_calculated_shipping_distance_update () RETURNS TRIGGER AS $f$
+BEGIN
+ NEW.shipping_distance := action.hold_copy_calculated_shipping_distance(NEW.hold,NEW.target_copy);
+ RETURN NEW;
+END;
+$f$ LANGUAGE PLPGSQL;
+
+CREATE TRIGGER hold_copy_shipping_distance_update_tgr BEFORE INSERT OR UPDATE ON action.hold_copy_map FOR EACH ROW EXECUTE PROCEDURE action.hold_copy_calculated_shipping_distance_update ();
+
+COMMIT;
\ No newline at end of file
--- /dev/null
+BEGIN;
+
+INSERT into config.org_unit_setting_type
+( name, grp, label, description, datatype, fm_class ) VALUES
+( 'circ.holds.target_sort_by_geographic_proximity', 'holds',
+ oils_i18n_gettext('circ.holds.target_sort_by_geographic_proximity',
+ 'Use shipping hub distance based hold targeting',
+ 'coust', 'label'),
+ oils_i18n_gettext('circ.holds.target_sort_by_geographic_proximity',
+ 'Use shipping hub distance based hold targeting',
+ 'coust', 'description'),
+ 'bool', null)
+;
+
+INSERT INTO config.global_flag (name, label, enabled)
+ VALUES (
+ 'circ.holds.distance_divisor',
+ oils_i18n_gettext(
+ 'circ.holds.distance_divisor',
+ 'Hold targeter will divide shipping distances by this number when scoring copies.',
+ 'cgf',
+ 'label'
+ ),
+ TRUE
+ );
+
+ALTER TABLE actor.org_unit
+ADD COLUMN shipping_hub_ou BIGINT REFERENCES actor.org_unit(id) ON DELETE SET NULL;
+
+CREATE OR REPLACE FUNCTION actor.list_org_unit_ancestor_shipping_hub(VARIADIC orgs NUMERIC[]) RETURNS TABLE(org_unit INT,hub INT)
+ AS
+$func$
+DECLARE
+ rec record;
+ cur_org INT;
+ next_hub INT;
+ org_id INT;
+BEGIN
+ FOREACH org_id IN ARRAY orgs LOOP
+ cur_org := org_id;
+ org_unit := cur_org;
+ LOOP
+ SELECT INTO next_hub actor.org_unit.shipping_hub_ou FROM actor.org_unit WHERE actor.org_unit.id = cur_org;
+ IF FOUND AND next_hub IS NOT NULL THEN
+ hub := next_hub;
+ return next;
+ EXIT;
+ END IF;
+ SELECT INTO cur_org parent_ou FROM actor.org_unit WHERE actor.org_unit.id = cur_org;
+ EXIT WHEN cur_org IS NULL;
+ END LOOP;
+ END LOOP;
+ RETURN;
+END;
+$func$ LANGUAGE PLPGSQL;
+
+CREATE TABLE actor.org_unit_shipping_hub_distance (
+ id SERIAL PRIMARY KEY,
+ orig_hub BIGINT NOT NULL REFERENCES actor.org_unit(id) ON DELETE CASCADE DEFERRABLE,
+ dest_hub BIGINT NOT NULL REFERENCES actor.org_unit(id) ON DELETE CASCADE DEFERRABLE,
+ distance INT NOT NULL
+);
+
+COMMIT;
\ No newline at end of file
[%- IF ctx.library.mailing_address; %]
<div id="addresses">
+ [%- IF ctx.library.mailing_address -%]
<div id="mailing" property="location address" typeof="PostalAddress">
<h3 property="contactType">[% l('Mailing address') %]</h3>
<span property="streetAddress">[% ctx.mailing_address.street1 | html %]
<span property="addressRegion">[% ctx.mailing_address.state | html %]</span><br />
<span property="addressCountry">[% ctx.mailing_address.country | html %]</span><br />
<span property="postalCode">[% ctx.mailing_address.post_code | html %]</span><br />
+ [%- IF ctx.mailing_address.latitude AND ctx.mailing_address.longitude -%]
+ <div>
+ <iframe width="500" height="400" frameborder="0" src="https://www.bing.com/maps/embed?h=400&w=500&cp=[% ctx.mailing_address.latitude %]~[% ctx.mailing_address.longitude %]&sp=point.[% ctx.mailing_address.latitude %]_[% ctx.mailing_address.longitude %]_[% ctx.library.name %]&lvl=16&typ=o&sty=r&src=SHELL&FORM=MBEDV8" scrolling="no">
+ </iframe>
+ <div style="white-space: nowrap; text-align: center; width: 500px; padding: 6px 0;">
+ <a id="largeMapLink" target="_blank" href="https://www.bing.com/maps?cp=[% ctx.mailing_address.latitude %]~[% ctx.mailing_address.longitude %]&sp=point.[% ctx.mailing_address.latitude %]_[% ctx.mailing_address.longitude %]_[% ctx.library.name %]&sty=r&lvl=16&FORM=MBEDLD">View Larger Map with Pin</a>
+ </div>
+ </div>
+ [%- END -%]
</div>
+ [%- END; -%]
</div>
[%- END; %]
</div>
<div class="row">
<div class="col-md-2">
+ <span>[% l('Shipping Hub OU') %]</span>
+ </div>
+ <div class="col-md-9">
+ <select class="form-control" ng-model="selectedOUType">
+ <option ng-repeat="t in outypes" value="{{t}}" ng-selected="t == selectedOUType">
+ {{getOUTypeLabel(t)}}
+ </option>
+ </select>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-2">
<button class="form-control" ng-click="reset()">[% l('Reset Form') %]</button>
</div>
<div class="col-md-9">
SELECT evergreen.populate_call_number(5, '780 A', 'IMPORT CONCERTO', 2); -- BR2
SELECT evergreen.populate_call_number(6, '781 D', 'IMPORT CONCERTO', 2); -- BR3
SELECT evergreen.populate_call_number(7, '781 G', 'IMPORT CONCERTO', 2); -- BR4
+SELECT evergreen.populate_call_number(11, 'LB 782 G', 'IMPORT CONCERTO', 2); -- BR5
SELECT evergreen.populate_call_number(9, '780 R', 'IMPORT CONCERTO', 2); -- BM1
-- Create copies
SELECT evergreen.populate_copy(5, 5, 'CONC50000', 'M'); -- BR2
SELECT evergreen.populate_copy(6, 6, 'CONC60000', 'M'); -- BR3
SELECT evergreen.populate_copy(7, 7, 'CONC70000', 'M'); -- BR4
+SELECT evergreen.populate_copy(11, 11, 'CONC80000', 'M'); -- BR5
SELECT evergreen.populate_copy(9, 9, 'CONC90000', 'M'); -- BM1
SELECT evergreen.populate_copy(4, 4, 'CONC41000', 'M'); -- BR1
* This will happily create duplicate addresses if given duplicate info.
*/
CREATE FUNCTION evergreen.create_aou_address
- (owning_lib INTEGER, street1 TEXT, street2 TEXT, city TEXT, state TEXT, country TEXT,
+ (owning_lib INTEGER, street1 TEXT, street2 TEXT, city TEXT, state TEXT, county TEXT, country TEXT,
post_code TEXT, address_type TEXT)
RETURNS void AS $$
BEGIN
- INSERT INTO actor.org_address (org_unit, street1, street2, city, state, country, post_code)
- VALUES ($1, $2, $3, $4, $5, $6, $7);
+ INSERT INTO actor.org_address (org_unit, street1, street2, city, state, county, country, post_code)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8);
- IF $8 IS NULL THEN
+ IF $9 IS NULL THEN
UPDATE actor.org_unit SET holds_address = currval('actor.org_address_id_seq'), ill_address = currval('actor.org_address_id_seq'), billing_address = currval('actor.org_address_id_seq'), mailing_address = currval('actor.org_address_id_seq') WHERE id = $1;
END IF;
- IF $8 ~ 'holds' THEN
+ IF $9 ~ 'holds' THEN
UPDATE actor.org_unit SET holds_address = currval('actor.org_address_id_seq') WHERE id = $1;
END IF;
- IF $8 ~ 'interlibrary' THEN
+ IF $9 ~ 'interlibrary' THEN
UPDATE actor.org_unit SET ill_address = currval('actor.org_address_id_seq') WHERE id = $1;
END IF;
- IF $8 ~ 'billing' THEN
+ IF $9 ~ 'billing' THEN
UPDATE actor.org_unit SET billing_address = currval('actor.org_address_id_seq') WHERE id = $1;
END IF;
- IF $8 ~ 'mailing' THEN
+ IF $9 ~ 'mailing' THEN
UPDATE actor.org_unit SET mailing_address = currval('actor.org_address_id_seq') WHERE id = $1;
END IF;
END
-- clean up our temp tables / functions
DROP TABLE marcxml_import;
-DROP FUNCTION evergreen.create_aou_address(INTEGER, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT);
+DROP FUNCTION evergreen.create_aou_address(INTEGER, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT,TEXT);
DROP FUNCTION evergreen.populate_call_number(INTEGER, TEXT, TEXT);
DROP FUNCTION evergreen.populate_call_number(INTEGER, TEXT, TEXT, INTEGER);
DROP FUNCTION evergreen.generate_price();
INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
(3, 1, 2, 'SYS2', oils_i18n_gettext(3, 'Example System 2', 'aou', 'name'));
INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
+ (10, 1, 2, 'SYS3', oils_i18n_gettext(10, 'Example System 3', 'aou', 'name'));
+INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
+ (12, 1, 2, 'SYS4', oils_i18n_gettext(12, 'Example System 4', 'aou', 'name'));
+INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
(4, 2, 3, 'BR1', oils_i18n_gettext(4, 'Example Branch 1', 'aou', 'name'));
INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
(5, 2, 3, 'BR2', oils_i18n_gettext(5, 'Example Branch 2', 'aou', 'name'));
INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
(7, 3, 3, 'BR4', oils_i18n_gettext(7, 'Example Branch 4', 'aou', 'name'));
INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
+ (11, 10, 3, 'BR5', oils_i18n_gettext(11, 'Example Branch 5', 'aou', 'name'));
+INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
+ (13, 12, 3, 'BR6', oils_i18n_gettext(13, 'Example Branch 6', 'aou', 'name'));
+INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
(8, 4, 4, 'SL1', oils_i18n_gettext(8, 'Example Sub-library 1', 'aou', 'name'));
INSERT INTO actor.org_unit (id, parent_ou, ou_type, shortname, name) VALUES
(9, 6, 5, 'BM1', oils_i18n_gettext(9, 'Example Bookmobile 1', 'aou', 'name'));
-INSERT INTO actor.org_lasso (id, name, global) VALUES (1000001, 'Even Branches', FALSE);
-INSERT INTO actor.org_lasso_map (lasso, org_unit) VALUES (1000001, 5), (1000001, 7);
-INSERT INTO actor.org_lasso (id, name, global) VALUES (1000002, 'Non-branches', TRUE);
-INSERT INTO actor.org_lasso_map (lasso, org_unit) VALUES (1000002, 8), (1000002, 9);
-- Address for the Consortium
-SELECT evergreen.create_aou_address(1, '123 Main St.', NULL, 'Anywhere', 'GA', 'US', '30303', NULL);
+SELECT evergreen.create_aou_address(1, '250 Georgia Ave SE #103', NULL, 'Atlanta', 'GA', 'Fulton', 'US', '30312', NULL);
-- Addresses for System 1
-SELECT evergreen.create_aou_address(2, '234 Side St.', NULL, 'Anywhere', 'GA', 'US', '30304', NULL);
+SELECT evergreen.create_aou_address(2, '1721 Waters Ave', NULL, 'Savannah', 'GA','Chatham', 'US', '31404', NULL);
-- Addresses for System 2
-SELECT evergreen.create_aou_address(3, '345 Corner Crescent', NULL, 'Elsewhere', 'GA', 'US', '30335', NULL);
+SELECT evergreen.create_aou_address(3, '831 Adams St', NULL, 'Macon', 'GA','Bibb', 'US', '31201', NULL);
+
+-- Addresses for System 3
+SELECT evergreen.create_aou_address(10, '215 N Lumpkin St', NULL, 'Athens', 'GA', 'Clarke', 'US', '30601', NULL);
+
+-- Addresses for System 4
+SELECT evergreen.create_aou_address(12, '625 Academy St NE', NULL, 'Gainesville', 'GA', 'Hall', 'US', '30501', NULL);
-- Addresses for Branch 1
-SELECT evergreen.create_aou_address(4, 'BR1', '123 Main St.', 'Anywhere', 'GA', 'US', '30303', 'billing mailing');
-SELECT evergreen.create_aou_address(4, 'Holds and ILL', '125 Main St.', 'Anywhere', 'GA', 'US', '30303', 'interlibrary holds');
+SELECT evergreen.create_aou_address(4, '250 Georgia Ave SE #103', NULL, 'Atlanta', 'GA', 'Hall','US', '30312', 'billing mailing');
+SELECT evergreen.create_aou_address(4, '250 Georgia Ave SE #103', NULL, 'Atlanta', 'GA', 'Hall','US', '30312', 'interlibrary holds');
-- Addresses for Branch 2
-SELECT evergreen.create_aou_address(5, 'BR2', '234 Side St.', 'Anywhere', 'GA', 'US', '30304', 'mailing');
-SELECT evergreen.create_aou_address(5, 'BR2 - Billing', '234 Side St.', 'Anywhere', 'GA', 'US', '30304', 'billing');
-SELECT evergreen.create_aou_address(5, 'BR2 - Holds and ILL', '234 Side St.', 'Anywhere', 'GA', 'US', '30304', 'interlibrary holds');
+SELECT evergreen.create_aou_address(5, '1721 Waters Ave', NULL, 'Savannah', 'GA','Chatham', 'US', '31404', 'mailing');
+SELECT evergreen.create_aou_address(5, '1721 Waters Ave', NULL, 'Savannah', 'GA','Chatham', 'US', '31404', 'billing');
+SELECT evergreen.create_aou_address(5, '1721 Waters Ave', NULL, 'Savannah', 'GA','Chatham', 'US', '31404', 'interlibrary holds');
-- Addresses for Branch 3
-SELECT evergreen.create_aou_address(6, 'BR3', '347 Corner Crescent', 'Elsewhere', 'GA', 'US', '30335', NULL);
+SELECT evergreen.create_aou_address(6, '831 Adams St', NULL, 'Macon', 'GA','Bibb', 'US', '31201', NULL);
-- Addresses for Branch 4
-SELECT evergreen.create_aou_address(7, 'BR4', '446 Nowhere Road', 'Elsewhere', 'GA', 'US', '30404', 'mailing');
-SELECT evergreen.create_aou_address(7, 'BR4 - Billing Dept', '446 Nowhere Road', 'Elsewhere', 'GA', 'US', '30404', 'billing');
-SELECT evergreen.create_aou_address(7, 'BR4 - Holds and ILL', '756 Industrial Lane', 'Elsewhere', 'GA', 'US', '30304', 'interlibrary holds');
+SELECT evergreen.create_aou_address(7, '419 7th St', NULL, 'Augusta', 'GA', 'Richmond', 'US', '30901', 'mailing');
+SELECT evergreen.create_aou_address(7, '419 7th St', NULL, 'Augusta', 'GA', 'Richmond','US', '30901', 'billing');
+SELECT evergreen.create_aou_address(7, '419 7th St', NULL, 'Augusta', 'GA', 'Richmond','US', '30901', 'interlibrary holds');
+
+-- Addresses for Branch 5
+SELECT evergreen.create_aou_address(11, '215 N Lumpkin St', NULL, 'Athens', 'GA', 'Clarke', 'US', '30601', NULL);
+
+-- Addresses for Branch 6
+SELECT evergreen.create_aou_address(13, '625 Academy St NE', NULL, 'Gainesville', 'GA', 'Hall', 'US', '30501', NULL);
-- Hours for branches
INSERT INTO actor.hours_of_operation (id, dow_0_open, dow_0_close, dow_1_open, dow_1_close,
(6, 'lib.info_url', '"http://br3.example.com"'), -- BR3
(7, 'lib.info_url', '"http://br4.example.com/info"'); -- BR4
+UPDATE actor.org_unit SET shipping_hub_ou = 4 WHERE id = 2;
+UPDATE actor.org_unit SET shipping_hub_ou = 7 WHERE id = 3;
+UPDATE actor.org_unit SET shipping_hub_ou = 11 WHERE id = 10;
+UPDATE actor.org_unit SET shipping_hub_ou = 13 WHERE id = 12;
+INSERT INTO actor.org_unit_shipping_hub_distance(orig_hub, dest_hub, distance) VALUES (4,4,0),(4,7,25),(4,11,50),(4,13,80),
+ (7,7,0),(7,4,25),(7,11,65),(7,13,35),
+ (11,7,65),(11,4,50),(11,11,0),(11,13,25),
+ (13,7,35),(13,4,80),(13,11,25),(13,13,0);
+
UPDATE actor.org_unit SET email = 'br1@example.com', phone = '(555) 555-0271' WHERE shortname = 'BR1';
UPDATE actor.org_unit SET email = 'br2@example.com', phone = '(555) 555-0272' WHERE shortname = 'BR2';
UPDATE actor.org_unit SET email = 'br3@example.com', phone = '(555) 555-0273' WHERE shortname = 'BR3';
UPDATE actor.org_unit SET email = 'br4@example.com', phone = '(555) 555-0274' WHERE shortname = 'BR4';
+UPDATE actor.org_unit SET email = 'br5@example.com', phone = '(555) 555-0275' WHERE shortname = 'BR5';
+UPDATE actor.org_unit SET email = 'br6@example.com', phone = '(555) 555-0276' WHERE shortname = 'BR6';
'T', bre.id, recipient.id, recipient.id,
recipient.home_ou, FALSE, NULL
);
+
+
-- title hold, circulator-placed
bre := evergreen.next_bib(bre.id);
EXIT WHEN bre IS NULL;
'M', 42, 2, 2, 9, FALSE, NULL,
'{"0":[{"_attr":"mr_hold_format","_val":"score"}]}'
);
+ -- title hold, resource sharing
+ PERFORM evergreen.populate_hold(
+ 'T', 9, 9, 9,
+ 13, FALSE, NULL
+ );
END $$;
editor_pane_parent_ou.validate(true);
editor_pane_parent_ou.setValue( this.store.getValue( current_ou, 'parent_ou' ) );
}
+
+ editor_pane_shipping_hub_ou.disabled = false;
+ editor_pane_shipping_hub_ou.required = false;
+ editor_pane_shipping_hub_ou.validate(true);
+ editor_pane_shipping_hub_ou.setValue( this.store.getValue( current_ou, 'shipping_hub_ou' ) );
editor_pane_opac_visible.setChecked( this.store.getValue( current_ou, 'opac_visible' ) == 't' ? true : false );
</div>
</td>
</tr>
+ <tr>
+ <th>&conify.org_unit.editor_pane.shipping_hub;</th>
+ <td>
+ <div
+ id="editor_pane_shipping_hub_ou"
+ dojoType="dijit.form.FilteringSelect"
+ jsId="editor_pane_shipping_hub_ou"
+ store="ou_list_store"
+ searchAttr="shortname"
+ ignoreCase="true"
+ >
+ <script type="dojo/method" event="onChange">
+<![CDATA[
+ if (current_ou && this.getValue()) this.store.setValue( current_ou, "shipping_hub_ou", this.getValue() );
+
+]]>
+ </script>
+ </div>
+ </td>
+ </tr>
<tr>
<th>&conify.org_unit.editor_pane.opac_visible;</th>
<td>
<!ENTITY conify.org_unit.editor_pane.main_phone "Main Phone Number">
<!ENTITY conify.org_unit.editor_pane.org_unit_type "Organization Unit Type">
<!ENTITY conify.org_unit.editor_pane.parent "Parent Organization Unit">
+<!ENTITY conify.org_unit.editor_pane.shipping_hub "Shipping Hub Organization Unit">
<!ENTITY conify.org_unit.editor_pane.opac_visible "OPAC Visible">
<!-- Hours of operation -->
<!ENTITY conify.org_unit.hoo_pane.title "Hours of Operation">