ACQ order record fetcher and uploader script
authorBill Erickson <berick@esilibrary.com>
Mon, 23 Jul 2012 14:44:41 +0000 (10:44 -0400)
committerMike Rylander <mrylander@gmail.com>
Tue, 24 Jul 2012 16:44:37 +0000 (12:44 -0400)
Some ACQ vendors support delivering MARC order record files directly
from their order system to an ILS via FTP.  (I've heard this called
"one-click" ordering in the past).  This commit includes a script to
seek out such order record files and pass them on to the Acquisitions
service for PO creation and potential activation.

The script supports a number of options, configured in opensrf.xml,
including which Vandelay (record import) options to use during record
import/merge/overlay.  See opensrf.xml.example for details.

Example:

./acq_order_reader.pl \
    --user admin \
    --password demo123 \
    -poll-interval 3 \
    --debug &

Signed-off-by: Bill Erickson <berick@esilibrary.com>
Signed-off-by: Mike Rylander <mrylander@gmail.com>
Open-ILS/examples/opensrf.xml.example
Open-ILS/src/support-scripts/acq_order_reader.pl [new file with mode: 0755]

index ea6f18b..b444cf3 100644 (file)
@@ -271,6 +271,54 @@ vim:et:ts=4:sw=4:
 
         </added_content>
 
+        <!-- Config section for acq_order_reader.pl script.
+             It reads MARC order record files from disk (presumably
+             an FTP destination) and pushes the order records into ACQ.
+             THIS IS NOT EDI. -->
+        <acq_order_reader>
+
+            <!-- Root directory for all FTP'd ACQ order record files .
+                 If the script is configured to talk to a remote acq server,
+                 this directory has to be a read/write NFS share.  -->
+            <base_dir>/openils/var/data/acq_orders/</base_dir>
+
+            <!-- any files found in the shared subdir must be inspected
+                 (e.g. file name prefix) to determine the provider. -->
+            <shared_subdir>ALL</shared_subdir><!-- SUPPORT PENDING -->
+
+            <!-- providers that don't provide a mechanism to inspect the file
+                 have to push their files to provider-specific locations -->
+            <provider>
+                <ordering_agency>BR1</ordering_agency> <!-- who gets/manages the order -->
+                <provider_code>BAB</provider_code>   
+                <provider_owner>CONS</provider_owner>  <!-- provider provider_owner; org unit shortname -->
+                <subdir>CONS-BAB</subdir> <!-- file directory;  full path = base_dir + subdir -->
+                <activate_po>false</activate_po> <!-- activate PO at upload? -->
+                <vandelay>
+                    <import_no_match>true</import_no_match>
+                    <!-- Most Vandelay options are supported.  For bools, use true/false.
+                        match_quality_ratio 
+                        match_set 
+                        bib_source 
+                        merge_profile
+                        create_assets
+                        import_no_match 
+                        auto_overlay_exact 
+                        auto_overlay_1match 
+                        auto_overlay_best_match
+                    -->
+                </vandelay>
+            </provider>
+
+            <!-- Add more as needed...
+            <provider>
+                ...
+            </provider>
+            -->
+
+        </acq_order_reader>
+
+
         <!-- no apps are enabled globally by default -->
         <activeapps/> 
 
diff --git a/Open-ILS/src/support-scripts/acq_order_reader.pl b/Open-ILS/src/support-scripts/acq_order_reader.pl
new file mode 100755 (executable)
index 0000000..ba0e2aa
--- /dev/null
@@ -0,0 +1,381 @@
+#!/usr/bin/perl
+# Copyright (C) 2012 Equinox Software, Inc.
+# Author: Bill Erickson <erickson@esilibrary.com>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+use strict; use warnings;
+use MARC::Record;
+use MARC::Batch;
+use MARC::File::XML ( BinaryEncoding => 'UTF-8' );
+use MARC::File::USMARC;
+
+use Data::Dumper;
+use File::Temp;
+use Getopt::Long qw(:DEFAULT GetOptionsFromArray);
+use Pod::Usage;
+use File::Spec;
+
+use OpenSRF::Utils::Logger qw/$logger/;
+use OpenSRF::AppSession;
+use OpenSRF::EX qw/:try/;
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Cache;
+use OpenILS::Utils::Cronscript;
+use OpenILS::Utils::CStoreEditor;
+use OpenILS::Utils::Fieldmapper;
+require 'oils_header.pl';
+use vars qw/$apputils/;
+
+my $acq_ses;
+my $authtoken;
+my $conf;
+my $cache;
+my $editor;
+my $base_dir;
+my $share_dir;
+my $providers;
+my $debug = 0;
+
+my %defaults = (
+    'osrf-config=s' => '/openils/conf/opensrf_core.xml',
+    'user=s'        => 'admin',
+    'password=s'    => '',
+    'nodaemon'      => 0,
+    'poll-interval=i' => 10
+);
+
+# -----------------------------------------------------
+# Command-line args reading / munging
+# -----------------------------------------------------
+$OpenILS::Utils::Cronscript::debug=1 if $debug;
+$Getopt::Long::debug=1 if $debug > 1;
+my $o = OpenILS::Utils::Cronscript->new(\%defaults);
+
+my @script_args = ();
+
+if (grep {$_ eq '--'} @ARGV) {
+    print "Splitting options into groups\n" if $debug;
+    while (@ARGV) {
+        $_ = shift @ARGV;
+        $_ eq '--' and last;    # stop at the first --
+        push @script_args, $_;
+    }
+} else {
+    @script_args = @ARGV;
+    @ARGV = ();
+}
+
+print "Calling MyGetOptions ",
+    (@script_args ? "with options: " . join(' ', @script_args) : 'without options from command line'),
+    "\n" if $debug;
+
+my $real_opts = $o->MyGetOptions(\@script_args);
+$o->bootstrap;
+
+my $osrf_config   = $real_opts->{'osrf-config'};
+my $oils_username = $real_opts->{user};
+my $oils_password = $real_opts->{password};
+my $help          = $real_opts->{help};
+my $poll_interval = $real_opts->{'poll-interval'};
+   $debug         += $real_opts->{debug};
+
+foreach (keys %$real_opts) {
+    print("real_opt->{$_} = ", $real_opts->{$_}, "\n") if $real_opts->{debug} or $debug;
+}
+
+# FEEDBACK
+
+pod2usage(1) if $help;
+unless ($oils_password) {
+    print STDERR "\nERROR: password option required for session login\n\n";
+}
+
+$debug and print Dumper($o);
+
+if ($debug) {
+    foreach my $ref (qw/osrf_config oils_username oils_password help debug/) {
+        no strict 'refs';
+        printf "%16s => %s\n", $ref, (eval("\$$ref") || '');
+    }
+}
+
+$debug and print Dumper($real_opts);
+
+# -----------------------------------------------------
+# subs
+# -----------------------------------------------------
+
+# log in
+sub new_auth_token {
+    $authtoken = oils_login($oils_username, $oils_password, 'staff') 
+        or die "Unable to login to Evergreen as user $oils_username";
+    return $authtoken;
+}
+
+# log out
+sub clear_auth_token {
+    $apputils->simplereq(
+        'open-ils.auth',
+        'open-ils.auth.session.delete',
+        $authtoken
+    );
+}
+
+sub push_file_to_acq {
+    my $file = shift;
+    my $args = shift;
+
+    $logger->info("acq-or: pushing file '$file' to provider " . $args->{provider});
+
+    # Cache the file name like Vandelay does.  ACQ will 
+    # read contents of the cache and then delete them.
+    # The key can be any unique value.
+    my $key = $$ . time . rand();
+    $cache->put_cache("vandelay_import_spool_$key", {path => $file});
+
+    # some arguments are not optional
+    $args->{create_po} = 1;
+
+    # don't send our internal args to the service
+    my $local = delete $args->{_local};
+
+    my $req = $acq_ses->request(
+        'open-ils.acq.process_upload_records',
+        $authtoken,
+        $key, 
+        $args
+    );
+
+    while (my $resp = $req->recv(timeout => 600)) {
+        if(my $content = $resp->content) {
+            $debug and print Dumper($content);
+        } else {
+            warn "Request returned no data: " . Dumper($resp) . "\n";
+        }
+    }
+
+    # TODO: delete tmp queue?
+}
+
+my %org_cache;
+sub org_from_sn {
+    my $sn = shift;
+    return $org_cache{$sn} if $org_cache{$sn};
+    my $org = $editor->search_actor_org_unit({shortname => $sn})->[0];
+    if (!$org) {
+        warn "No such org unit in acq_order_reader config: '$sn'\n";
+        return undef;
+    }
+    return $org_cache{$sn} = $org;
+}
+
+# translate config info into a request arguments structure
+sub args_from_provider_conf {
+    my $conf = shift;
+    my %args;
+
+    my $pcode = $conf->{code};
+    my $orgsn = $conf->{owner};
+
+    $debug and print "Extracting request args for provider $pcode at $orgsn\n";
+
+    my $org = org_from_sn($conf->{owner}) or return undef;
+
+    my $provider = $editor->search_acq_provider({
+        code => $pcode,
+        owner => $org->id
+    })->[0];
+
+    if (!$provider) {
+        warn "No such provider in acq_order_reader config: '$pcode'\n";
+        return undef;
+    }
+
+    my $oa = org_from_sn($conf->{ordering_agency}) or return undef;
+
+    $args{provider} = $provider->id;
+    $args{ordering_agency} = $oa->id;
+    $args{activate_po} = ($conf->{activate_po} || '') =~ /true/i;
+    
+    # vandelay import options
+    my $vconf = $conf->{vandelay} || {};
+    $args{vandelay} = {};
+
+    # value options
+    for my $opt (
+        qw/
+            match_quality_ratio 
+            match_set 
+            bib_source 
+            merge_profile / ) {
+
+        $args{vandelay}->{$opt} = $vconf->{$opt} 
+    }
+
+    # bool options
+    for my $opt (
+        qw/
+            create_assets
+            import_no_match 
+            auto_overlay_exact 
+            auto_overlay_1match 
+            auto_overlay_best_match/ ) {
+
+        $args{vandelay}->{$opt} = 1 if ($vconf->{$opt} || '') =~ /true/i;
+    }
+
+    if ($vconf->{queue}) {
+        $args{vandelay}->{queue_name} = $vconf->{queue};
+        $args{vandelay}->{existing_queue} = $vconf->{queue};
+
+    } else {
+
+        # create a temporary queue
+        $args{vandelay}->{queue_name} = sprintf("acq-order-reader-%s-%s-%s", 
+            $org->shortname, $provider->code, $apputils->epoch2ISO8601(time));
+    }
+
+    $args{_local} = {
+        provider_code => $pcode, # good for debugging
+        dirname => File::Spec->catfile($base_dir, $conf->{subdir})
+    };
+
+    return \%args;
+}
+
+# returns the list of new order record files that
+# need to be processed for this vendor
+sub check_provider_files {
+    my $args = shift;
+    my $dirname = $args->{_local}->{dirname};
+    my $dh;
+    my @files;
+
+    $debug and print "Searching for new files at $dirname\n";
+
+    if ( !opendir($dh, $dirname) ) {
+        warn "Couldn't open dir '$dirname': $!";
+        return @files;
+    }
+
+    @files = readdir $dh;
+    # ignore '.', '..', and hidden files
+    @files = grep {$_ !~ /^\./} @files;
+
+    $logger->info("acq-or: found " . scalar(@files) . " ACQ order files at $dirname");
+
+    # return the file names w/ full path
+    return map {File::Spec->catfile($dirname, $_)} @files;
+}
+
+# -----------------------------------------------------
+# Main script
+# -----------------------------------------------------
+
+osrf_connect($osrf_config);
+
+$conf = OpenSRF::Utils::SettingsClient->new;
+$cache = OpenSRF::Utils::Cache->new;
+$editor = OpenILS::Utils::CStoreEditor->new;
+$acq_ses = OpenSRF::AppSession->create('open-ils.acq');
+
+my $user = $editor->search_actor_user({usrname => $oils_username})->[0];
+if (!$user) {
+    warn "Invalid user: $oils_username\n";
+    exit;
+}
+
+# read configs
+$base_dir = $conf->config_value(acq_order_reader => 'base_dir');
+$share_dir = $conf->config_value(acq_order_reader => 'shared_subdir');
+$providers = $conf->config_value(acq_order_reader => 'provider');
+$providers = [$providers] unless ref $providers eq 'ARRAY';
+
+$debug and print Dumper($providers);
+
+# -----------------------------------------------------
+# main loop
+# for each provider directory, plus the shared directory, check
+# to see if there are any files pending.  For any files found, push
+# them up to the ACQ service, then delete the file
+while (1) {
+
+    new_auth_token();
+    my $processed = 0;
+
+    # explicit providers
+    for my $provider_conf (@$providers) {
+        my $args = args_from_provider_conf($provider_conf) or next;
+        my @files = check_provider_files($args);
+        push_file_to_acq($_, $args) for @files;
+        $processed += scalar(@files);
+    }
+    
+    # shared directory
+    # TODO
+
+    clear_auth_token();
+
+    $logger->info("acq-or: loop processed $processed files");
+
+    $SIG{INT} = sub { 
+        print "Cleaning up...\n";
+        exit; # allows lockfile cleanup
+    };
+
+    # processing takes time.  If we processed any records
+    # during the current iteration, immediately check again
+    # for more work.  Otherwise, wait $poll_interval seconds
+    sleep $poll_interval if $processed == 0;
+}
+
+__END__
+
+=head1 NAME
+
+acq_order_reader.pl - Collect MARC order record files and pass them onto the ACQ service
+
+=head1 SYNOPSIS
+
+./acq_order_reader.pl [script opts ...]
+
+This script uses the EG common options from B<Cronscript>.  See --help output for those.
+
+Run C<perldoc marc_stream_importer.pl> for full documentation.
+
+Note: this script has to be run in the same directory as B<oils_header.pl>.
+
+Typical server-style execution will include a trailing C<&> to run in the background.
+
+=head1 OPTIONS
+
+The only required option is --password
+
+ --password         =<eg_password>
+ --user             =<eg_username>  default: admin
+ --nodaemon                         default: OFF       When used with --spoolfile, turns off Net::Server mode and runs this utility in the foreground
+
+
+=head2 Old style: --noqueue and associated options
+
+=head1 EXAMPLES
+
+./acq_order_reader.pl --user admin --password demo123
+
+./acq_order_reader.pl --user admin --password demo123 -poll-interval 3 --debug --nodaemon
+
+=head1 AUTHORS
+
+    Bill Erickson <erickson@esilibrary.com>
+    Code liberally copied from marc_stream_importer.pl
+
+=cut