RSS Git Download  Clone
Raw Blame History
package LIMS::Local::PAS;

=begin nd
Class: LIMS::Local::PAS
exports nothing, call PAS->new(\%args)->query() or PAS->new(\%args)->demographics()
returns ref to array of hashrefs (last_name, first_name, dob, nhs_number, case_number),
or string if nothing found; see __END__ for list of PAS codes
=cut

use LWP::UserAgent;
use LIMS::Local::Debug;
use XML::Simple qw(:strict); # need to specify ForceArray & KeyAttr if using :strict here
use Data::Dumper;

use Moose;
use MooseX::AttributeHelpers;
use namespace::clean -except => 'meta';

# args passed in from calling class:
has [ qw(config patient messages) ] => (
    is  => 'ro',
    isa => 'HashRef',
    required  => 1,
);

has xml => (
	is  => 'ro',
	isa => 'ArrayRef[HashRef]',
	default => sub { [] },
	metaclass => 'Collection::Array',
	provides  => { push => 'add_to_xml' }, # MooseX::AttributeHelpers
);

has diffs => (
	is  => 'ro',
	isa => 'ArrayRef[HashRef]',
	default => sub { [] },
	metaclass => 'Collection::Array',
	provides  => {
        push  => 'add_to_diffs',
        count => 'have_diffs', # predicate => has_diffs doesn't work: '[]' is always true
    }, # MooseX::AttributeHelpers
);

has response => (
    is  => 'ro',
    isa => 'ArrayRef[HTTP::Response]', # HTTP::Response objects
    default => sub { [] },
	metaclass => 'Collection::Array',
	provides  => { push => 'add_to_response' }, # MooseX::AttributeHelpers
);

has errors => (
    is  => 'ro',
    isa => 'ArrayRef[Str]', # $r->status_line
    default => sub { [] },
	metaclass => 'Collection::Array',
	provides  => { push => 'add_to_errors' }, # MooseX::AttributeHelpers
);

has patient_validated => (
	is  => 'rw',
	isa => 'Int',
	predicate => 'have_valid_patient',
#    trigger => sub { warn 'patient_validated' },
);

has user_agent => (
	is  => 'ro',
	isa => 'LWP::UserAgent',
	lazy_build => 1, # equivalent to lazy => 1, builder => _build_foo
);

has searchable_field_names => (
    is  => 'rw',
    isa => 'ArrayRef[Str]',
    default => sub { [] },
);

__PACKAGE__->meta->make_immutable;

my $fh; # uncomment line below to debug query:
# open $fh, '>', '/home/raj/www/apps/HMDS/trunk/logs/pas.txt';

# TODO: make these part of object:
# list of searchable patient params to create PAS query:
my @INITIAL_SEARCH_FIELDS = qw(unit_number nhs_number);
my @FOLLOW_UP_SEARCH_FIELDS = qw(
    last_name
    first_name
    unit_number
    nhs_number
    post_code
    dob
);

# list of patient params for comparison in detect_diffs():
my @PATIENT_FIELDS = qw(last_name first_name dob nhs_number);

# conversion table for converting patient params to PAS terms:
my %PAS_TERMS = (
    last_name   => 'SN',
    first_name  => 'FN',
    unit_number => 'PAS',
	nhs_number  => 'NNN',
	post_code   => 'PCODE',
    dob         => 'DOB',
);

# variable: $QUERY_STRING_SEPARATOR
# query string separator character
my $QUERY_STRING_SEPARATOR = '&';

=begin nd
Function: query()
Query PAS system to check submitted demographic data. Uses XML::Simple::XMLin()
to parse content returned by LWP::UserAgent from PAS into a Perl-readable complex
data structure. Returns a ref to an array of hashrefs of PAS demographic data if any
patient data field differs from PAS data, or a status message (eg no PAS matches,
demographic data verified, PAS server error, etc)
=cut

#-------------------------------------------------------------------------------
sub query {
	my $self = shift;

    my $patient = $self->patient; # DEBUG $patient;

    # load list of field names on which to do PAS search:
    $self->set_searchable_fields($patient);

    my $unit_number_data = $patient->{unit_number};

    # $patient->{unit_number} can be string or arrayref:
    if ( ref $unit_number_data eq 'ARRAY' ) { # warn 'ARRAY';
        UNIT_NUMBER:
        foreach my $unit_number (@$unit_number_data) { # warn $unit_number;
            # need to clone patient so we don't clobber $patient->{unit_number}:
            my %cloned_patient = %$patient;
            # replace unit_number array with each string value:
            $cloned_patient{unit_number} = $unit_number;

            my $response = $self->do_query(\%cloned_patient) || next UNIT_NUMBER;
            $self->add_to_response($response);
        }
    }

    else {
        my $response = $self->do_query($patient); # warn Dumper $response;
        $self->add_to_response($response) if $response; # or will crash Moose
    }

	my $result = $self->process_response;

	return $result;
}

=begin nd
Function: do_query()
Called by <query()> on each entry in patient unit_number array. Returns empty
if it can't create a query string from submitted patient params. Uses LWP::UserAgent
to send http query to PAS server, and returns HTTP::Response, or error message.
=cut

#-------------------------------------------------------------------------------
sub do_query {
	my $self    = shift;
    my $patient = shift;

    my $config = $self->config;

    my $searchable_field_names = $self->searchable_field_names;

	# variable $query
	# Constructs query string to send to PAS interface. Generates list of name=value
    # pairs from $patient hashref. Converts application fieldname -> PAS equivalent
    # fieldname if necessary (eg nhs_number => 'NNN')
	my $query = join $QUERY_STRING_SEPARATOR,
        map {
            sprintf '%s=%s',
                $PAS_TERMS{$_} || $_,
                $patient->{$_};
        } grep $patient->{$_}, @$searchable_field_names; # warn $query;

    return unless $query;

	# full url for query:
    my $url = sprintf 'http://%s?CMD=GETPAT&%s',
        $config->{pas_address},
        $query; # warn $url;

    my $ua = $self->user_agent;

	# variable: $response
	# captures return from query sent to PAS interface
    my $response;

	eval { # in case we're in off-line testing mode
		local $SIG{ALRM} = sub {
			die $self->messages->{pas_timeout} . "\n"; # NB \n required
		};
		alarm 15;
		$response = $ua->get( $url ); # DEBUG($response);
		alarm 0;
	};

	return $response;
}

=begin nd
Function: process_response()
Called by <query()> to processes $self->response arrayref. Returns 'insufficient info'
message if array empty. Otherwise, analyses each HTTP::Response object for
'is_success' flag. If present, parses $o->content with XML::Simple::XMLin,
and pushes $xml onto $self->xml array if $xml->{PERSON} exists. If 'is_success'
not set, pushes $o->status_line onto $self->errors array. Function returns 'result',
either from <process_xml()> if $self->xml exists, or from $self->errors if exists,
otherwise 'no PAS matches'
=cut

#-------------------------------------------------------------------------------
sub process_response {
	my $self = shift;

	my $response = $self->response; # warn Dumper $response;
    my $messages = $self->messages; # DEBUG $messages;

    # empty $response array means insufficient info to process query:
    return $messages->{pas_insufficient_info} unless @$response;

	RESPONSE:
    foreach my $r (@$response) { # $self->_debug($r);
		# if not success, capture message:
		unless ($r->is_success) {
			$self->add_to_errors($r->status_line);
			next RESPONSE;
		}

		my $xml = XMLin($r->content, ForceArray => 0, KeyAttr => '');

		# if PERSON block populated, we have a result:
		if ($xml->{PERSON}) { # warn Dumper $xml;
			$self->add_to_xml($xml);
		}
    }

	my $result;

	# $self->xml means we have a result:
	if ( @{ $self->xml } ) { # warn Dumper $self->xml;
        $result = $self->process_xml;
	}
	# $self->errors means one (or more) responses failed is_success() test:
	elsif ( my @errs = @{ $self->errors } ) {
		$result = join ', ', @errs; warn $result;
	}
	# all responses = is_success() and no $xml->{PERSON} = no PAS matches:
	else {
		$result = $messages->{pas_no_matches};
	}

	return $result;
}

=begin
#	if ($@) { # doesn't trigger $@ after alarm period expired so $response contains HTTP::Request object
	# $response->{_msg} contains query status (eg 'OK', or failure message):
	if ($response->{_msg} =~ /pas_server_timeout/) { # pas_server_timeout = PAS error msg
		$response = $messages->{pas_timeout};
	} # DEBUG ($response);

=cut

=begin nd
Function: process_xml()
Called by <process_response()> if $self->xml array exists. Sends each XMLin
object in $self->xml array to <process_patient_data()> which sets $self->diffs
or $self->patient_validated, and function return depends on which one is set.
=cut

#-------------------------------------------------------------------------------
sub process_xml {
	my $self = shift;

	foreach my $xml ( @{ $self->xml } ) {
		# $xml->{PERSON} exists, checked in RESPONSE block:
		my $patient_data = $xml->{PERSON};

		# If $patient_data is single hashref send to <_process_patient_data()>,
		# which calls <_detect_diffs> to compare patient data with PAS
		# demographics, pushing hashrefs of mismatching patient data into
		# $self->diffs array. If $patient_data is arrayref then it contains data
		# on > 1 patient. Send each dataset to <_process_patient_data()>:
		if (ref $patient_data eq 'ARRAY') { # warn 'ARRAY';
			$self->process_patient_data($_) for @$patient_data;
		}
		else { # warn 'NOT ARRAY';
			$self->process_patient_data($patient_data);
		}
	}

	my $result;

	# should now have either $self->diffs or $self->patient_validated:
	if ($self->have_diffs) { # warn Dumper $self->diffs;
		$result = $self->diffs;
	}
	elsif ($self->have_valid_patient) { # warn 'validated';
		$result = $self->messages->{pas_verified};
	}
	else {
    # TODO: is ok if query for new_patient without nhs_no - might not have     
		#die 'shouldn\'t get here - have neither $self->diffs
		#	or $self->patient_validated';
	}

	return $result;
}

=begin nd
Function: process_patient_data()
Called by <process_xml()> on each $xml->{PERSON} dataset. Calls <detect_diffs()>
on each <PATIENT_ID> block, until $self->patient_validated detected. Might
return empty if no unit_number matches and no nhs_number supplied.
=cut

#-------------------------------------------------------------------------------
sub process_patient_data {
	my $self = shift;

	# variable: $pas_dataset
	# hashref containing XML %PERSON dataset
	my $pas_dataset = shift; # DEBUG($pas_dataset);

	# variable: $case_number_data
	# ref to array of 'content' and 'TYPE' hashrefs from PATIENT_IDS block
	#(content = case_number, TYPE = [CPSJ])
	# numbers such as case-note numbers and PAS numbers will be tied to a patient
	# record as <PATIENT_IDS> elements with one of four attributes to identify
	# their <TYPE> as follows:
	# C = LGI case-note number - there may be multiple of these per patient
	# P = LGI PAS number
	# S = SMS Number
	# J = SJ PAS number

	my $patient_ids = $pas_dataset->{PATIENT_IDS}->{PATIENT_ID}; # DEBUG($patient_ids);

	my $patient = $self->patient; # warn Dumper $patient;

	# function: ENTRY
	# send each (TYPE != SMS) entry in $patient_ids arrayref + %PERSON hashref
	# ($pas_dataset) to <_detect_diffs()> if PAS case-number ($entry->{content}) matches
	# patient unit_number. $entry = hashref of 'content' and 'TYPE' from PATIENT_IDS
	# block (content = case_number, TYPE = [CPSJ])

    if ( my $patient_unit_number = $patient->{unit_number} ) { # warn Dumper $unit_number;
        if ( ref $patient_unit_number eq 'ARRAY' ) {
            UNIT_NUMBER:
            foreach my $unit_number (@$patient_unit_number) {
                ENTRY:
                foreach my $entry (@$patient_ids) { # warn Dumper $entry;
                    next ENTRY if $entry->{TYPE} eq 'S'; # skip SMS numbers
                    next ENTRY unless $entry->{content} eq $unit_number;
                    $self->detect_diffs($pas_dataset, $entry);
                }
                last UNIT_NUMBER if $self->have_valid_patient; # no need to proceed further
            }
        }
        else {
            ENTRY:
            foreach my $entry (@$patient_ids) { # warn Dumper $entry;
                next ENTRY if $entry->{TYPE} eq 'S'; # skip SMS numbers
                next ENTRY unless $entry->{content} eq $patient_unit_number;
                $self->detect_diffs($pas_dataset, $entry);
            }
        }
	}

    return if $self->have_valid_patient; # no need to proceed further

	# if unit_number not supplied, or doesn't match PAS demographics,
    # $self->patient_validated will be undef so try nhs_number
    # (if supplied), or a direct request from pas.cgi for PAS search
# TODO: do we need this:
	return unless grep $patient->{$_}, qw( nhs_number _do_pas_search);

	{ # same as previous ENTRY block, but omits unit_number checks
		ENTRY:
		foreach my $entry (@$patient_ids) {
			next ENTRY if $entry->{TYPE} eq 'S'; # skip SMS numbers
			$self->detect_diffs($pas_dataset, $entry);

            # exit loop if $self->patient_validated flag set in _detect_diffs():
            last ENTRY if $self->have_valid_patient;
		}
	}
}

=begin nd
Function: detect_diffs()
Called by <process_single_patient()> to perform comparison between PAS and
patient datasets. Constructs temporary hash of fieldname/value from PAS data,
and pushes ref to it onto $self->diffs arrayref (via $self->add_to_diffs) if
any of the PAS values differs from corresponding patient data
=cut

#-------------------------------------------------------------------------------
sub detect_diffs {
	my ($self, $pas_dataset, $patient_id) = @_;
		# DEBUG($pas_dataset, $patient_id);

    my $diffs = $self->diffs;

    # return if PAS unit_number already included in $self->diffs array:
    return if grep {
        $patient_id->{content} eq $_->{unit_number};
    } @$diffs;

	# variable: $address
	# string constructed from %PERSON ADDRESS1 & ADDRESS2 fields that are not
	# empty hashref fields
	my $address = join ', ', map $pas_dataset->{ADDRESSES}->{ADDRESS}->{$_},
		grep ! ref $pas_dataset->{ADDRESSES}->{ADDRESS}->{$_}, qw/ADDRESS1 ADDRESS2/;
		# ADDRESS3 - don't need it

	# variable: %demographics
	# hash of patient demographic data constructed from PAS dataset & case_number_data,
	# to be stored in $self->diffs arrayref if any of the values differ from
	# corresponding patient data field.
	# Address & zip keys used by Pas::do_search() only
	my %demographics = (
		last_name  	=> $pas_dataset->{DEMOGRAPHICS}->{SURNAME},
		first_name 	=> $pas_dataset->{DEMOGRAPHICS}->{FORENAME},
		dob 	   	=> $pas_dataset->{DEMOGRAPHICS}->{DOB},
		nhs_number 	=> $pas_dataset->{DEMOGRAPHICS}->{NNN},
		unit_number => $patient_id->{content},
		address		=> substr($address, 0, 30), # truncate at x chars
		zip			=> $pas_dataset->{ADDRESSES}->{ADDRESS}->{POSTCODE},
	); # DEBUG \%demographics;

	# empty vals are in the form of an empty hashrefs, so need coverting to empty
	# string for printing (or get hashref memory address printed):
	while ( my ($key, $value) = each %demographics ) {
		$demographics{$key} = '' if ref $value; # convert empty hashref to string
	}

	my $patient = $self->patient;

    {
        no warnings 'uninitialized';
        # map { DEBUG "PAS: $demographics{$_}; patient: $patient->{$_}" } @PATIENT_FIELDS;
    }

	# send %demographics to $self->diffs if any of the values differs from
	# corresponding patient data:
	my $difference_detected = grep {
		lc $demographics{$_} ne lc $patient->{$_}
	} @PATIENT_FIELDS;

	if ( $difference_detected ) {
        $self->add_to_diffs(\%demographics);
    }
    # all submitted patient vals match PAS vals, so set 'validated' flag:
    else {
        $self->patient_validated(1);
    }
}


=begin nd
Function: set_searchable_fields()
initial query from Register searches only unit_number & nhs_number, follow-up
search can include all patient demographics fields - uses '_do_pas_search' flag
in query to distinguish.
=cut

#-------------------------------------------------------------------------------
sub set_searchable_fields {
    my $self    = shift;
    my $patient = shift;

    if ( $patient->{_do_pas_search} ) {
        $self->searchable_field_names(\@FOLLOW_UP_SEARCH_FIELDS);
    }
    else {
        $self->searchable_field_names(\@INITIAL_SEARCH_FIELDS);
    }
    $self->searchable_field_names or die 'Failed to set searchable filed names';
}


=begin nd
Function: _build_user_agent()
Moose 'lazy_build' function, called on first call to $self->user_agent. Returns
LWP::UserAgent object.
=cut

#-------------------------------------------------------------------------------
sub _build_user_agent {
    my $self = shift; # warn '_build_user_agent'; # should only be called once

    # use a private var to hold UserAgent object as we can be called >1 per query:
    # return $self->{__user_agent} if $self->{__user_agent};

    my $config = $self->config;

	# variable: $ua
	# creates LWP::UserAgent object
 	my $ua = LWP::UserAgent->new;
    # $ua->timeout(20); # pas server handles this, but need it for off-line testing

	# variable: $pas_server_address
	#  PAS interface IP (163.160.107.60 = live system, 163.160.104.239/sysdev01 = test system)

	my $pas_addr = $config->{pas_address}; # warn $config->{pas_address};
    my $username = $config->{pas_username};
    my $password = $config->{pas_pwd};

    # get server IP address from $pas_addr (ie (foo)/bar/etc):
    my ($server_ip_addr) = $pas_addr =~ m!\A(.*?)/.*!; # warn $server_ip_addr;

	# supply port, username & password to PAS interface for authorisation
	$ua->credentials( $server_ip_addr . ':80', '', $username, $password );

    # add user_agent to $self for _do_query():
    # $self->{__user_agent} = $ua;

    return $ua;
}

#-------------------------------------------------------------------------------
sub _debug {
	my $self = shift;
	my $r = shift;

	return unless $fh;

    print $fh Dumper (
		$r->is_success, # 1
		$r->content,
		$r->status_line, # 200 OK
		$r->as_string,
		$r->message, # OK
	);
}

1;

__END__
How to query cross-city index:
command: GETPAT
parameter: PATID|NNN|PAS|PASTYPE|SN|FN|SXFN|SXSN|INIT|DOB|ADDR1|PCODE|GP|PRAC|SITE|STYPE|STQ
example: http://server:port/xciservlet?CMD=GETPAT&SN=BLOGGS&FN=JOE

Abbreviations:
ADDR1       Any address line
DOB         Date of Birth
FN          Forename
GP          National GP code
INIT        Initials
NNN         NHS Number
PAS         Patient Administration System, any PAS or case-note or SMS number
PASTYPE     A code to identify a number as a PAS, case-note or SMS number
PATID       PATIENT_ID
PCODE       Postcode
PRAC        National Practice code
SITE        Which SITE data is required from
SN          Surname
STQ         Start Query
STYPE       How matches should be defined � exact, starting with or containing the specified text
SXFN        Soundex of Forename
SXSN        Soundex of Surname

1;