Koha/C4/SIP/ILS/Patron.pm
Nick Clemens f747d38aea Bug 29755: Check each NoIssuesCharge separately
This patch updates SIP patron code to use account methods to calculate balances
over the patronflags returns. It also checks if patron should be blocked for each
'No Issues charge' preference

Tests are added for NoIssuesChargeGuarantees

To test:
 1 - Set noissuescharge preference to 5
 2 - Add a $10 charge to a patron
 3 - perl misc/sip_cli_emulator.pl -a localhost -p 6001 -su term1 -sp term1 -l CPL -m patron_information --patron BARCODE
 4 - Note the 64 message starts with Y's that mean patron is blocked
 5 - Set noissuescharge to 11
 6 - Repeat 3, patron is no longer blocked
 7 - Set NoIssuesChargeGuarantees to 8
 8 - Repeat 3, patron is blocked
 9 - Pay $3 on patron so they owe 7
10 - Repeat 3, patron is not blocked
11 - Add a child account with patron as guarantor
12 - Repeat 3, patron is not blocked
13 - Add a $4 charge to child
14 - Repeat 3, patron is blocked
15 - Repeat 3, but with child barcode, child is not blocked
16 - Set NoIssuesChargeGuarantorsWithGuarantees to 10
17 - Repeat 3, patron is blocked
18 - Repeat 3 with child barcode, child is blocked

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
Signed-off-by: Fridolin Somers <fridolin.somers@biblibre.com>
2022-05-06 15:58:13 -10:00

745 lines
25 KiB
Perl

#
# ILS::Patron.pm
#
# A Class for hiding the ILS's concept of the patron from the OpenSIP
# system
#
package C4::SIP::ILS::Patron;
use strict;
use warnings;
use Exporter;
use Carp;
use C4::SIP::Sip qw(siplog);
use Data::Dumper;
use C4::SIP::Sip qw(add_field maybe_add);
use C4::Context;
use C4::Koha;
use C4::Members;
use C4::Reserves;
use C4::Auth qw(checkpw);
use Koha::Items;
use Koha::Libraries;
use Koha::Patrons;
our $kp; # koha patron
=head1 Methods
=cut
sub new {
my ($class, $patron_id) = @_;
my $type = ref($class) || $class;
my $self;
my $patron;
if ( ref $patron_id eq "HASH" ) {
if ( $patron_id->{borrowernumber} ) {
$patron = Koha::Patrons->find( $patron_id->{borrowernumber} );
} elsif ( $patron_id->{cardnumber} ) {
$patron = Koha::Patrons->find( { cardnumber => $patron_id->{cardnumber} } );
} elsif ( $patron_id->{userid} ) {
$patron = Koha::Patrons->find( { userid => $patron_id->{userid} } );
}
} else {
$patron = Koha::Patrons->find( { cardnumber => $patron_id } )
|| Koha::Patrons->find( { userid => $patron_id } );
}
unless ($patron) {
siplog("LOG_DEBUG", "new ILS::Patron(%s): no such patron", $patron_id);
return;
}
$kp = $patron->unblessed;
my $pw = $kp->{password};
my $flags = C4::Members::patronflags( $kp );
my $debarred = $patron->is_debarred;
my ($day, $month, $year) = (localtime)[3,4,5];
my $today = sprintf '%04d-%02d-%02d', $year+1900, $month+1, $day;
my $expired = ($today gt $kp->{dateexpiry}) ? 1 : 0;
if ($expired) {
if ($kp->{opacnote} ) {
$kp->{opacnote} .= q{ };
}
$kp->{opacnote} .= 'PATRON EXPIRED';
}
my %ilspatron;
my $adr = _get_address($kp);
my $dob = $kp->{dateofbirth};
$dob and $dob =~ s/-//g; # YYYYMMDD
my $dexpiry = $kp->{dateexpiry};
$dexpiry and $dexpiry =~ s/-//g; # YYYYMMDD
# Get fines and add fines for guarantees (depends on preference NoIssuesChargeGuarantees)
my $fines_amount = ($patron->account->balance > 0) ? $patron->account->non_issues_charges : 0;
my $fee_limit = _fee_limit();
my $fine_blocked = $fines_amount > $fee_limit;
my $noissueschargeguarantorswithguarantees = C4::Context->preference('NoIssuesChargeGuarantorsWithGuarantees');
my $noissueschargeguarantees = C4::Context->preference('NoIssuesChargeGuarantees');
if ( $noissueschargeguarantorswithguarantees ) {
$fines_amount += $patron->relationships_debt({ include_guarantors => 1, only_this_guarantor => 0, include_this_patron => 0 });
$fine_blocked ||= $fines_amount > $noissueschargeguarantorswithguarantees;
} elsif ( $noissueschargeguarantees ) {
$fines_amount += $patron->relationships_debt({ include_guarantors => 0, only_this_guarantor => 0, include_this_patron => 0 });
$fine_blocked ||= $fines_amount > $noissueschargeguarantees;
}
my $circ_blocked =( C4::Context->preference('OverduesBlockCirc') ne "noblock" && defined $flags->{ODUES}->{itemlist} ) ? 1 : 0;
{
no warnings; # any of these $kp->{fields} being concat'd could be undef
%ilspatron = (
name => $kp->{firstname} . " " . $kp->{surname},
id => $kp->{cardnumber}, # to SIP, the id is the BARCODE, not userid
password => $pw,
ptype => $kp->{categorycode}, # 'A'dult. Whatever.
dateexpiry => $dexpiry,
dateexpiry_iso => $kp->{dateexpiry},
birthdate => $dob,
birthdate_iso => $kp->{dateofbirth},
branchcode => $kp->{branchcode},
library_name => "", # only populated if needed, cached here
borrowernumber => $kp->{borrowernumber},
address => $adr,
home_phone => $kp->{phone},
email_addr => $kp->{email},
charge_ok => ( !$debarred && !$expired && !$fine_blocked && !$circ_blocked),
renew_ok => ( !$debarred && !$expired && !$fine_blocked),
recall_ok => ( !$debarred && !$expired && !$fine_blocked),
hold_ok => ( !$debarred && !$expired && !$fine_blocked),
card_lost => ( $kp->{lost} || $kp->{gonenoaddress} || $flags->{LOST} ),
claims_returned => 0,
fines => $fines_amount,
fees => 0, # currently not distinct from fines
recall_overdue => 0,
items_billed => 0,
screen_msg => 'Greetings from Koha. ' . $kp->{opacnote},
print_line => '',
items => [],
hold_items => $flags->{WAITING}->{itemlist},
overdue_items => $flags->{ODUES}->{itemlist},
too_many_overdue => $circ_blocked,
fine_items => [],
recall_items => [],
unavail_holds => [],
inet => ( !$debarred && !$expired ),
debarred => $debarred,
expired => $expired,
fine_blocked => $fine_blocked,
fee_limit => $fee_limit,
userid => $kp->{userid},
);
}
if ( $patron->is_debarred and $patron->debarredcomment ) {
$ilspatron{screen_msg} .= " -- " . $patron->debarredcomment;
}
if ( $circ_blocked ) {
$ilspatron{screen_msg} .= " -- " . "Patron has overdues";
}
for (qw(EXPIRED CHARGES CREDITS GNA LOST NOTES)) {
($flags->{$_}) or next;
if ($_ ne 'NOTES' and $flags->{$_}->{message}) {
$ilspatron{screen_msg} .= " -- " . $flags->{$_}->{message}; # show all but internal NOTES
}
if ($flags->{$_}->{noissues}) {
foreach my $toggle (qw(charge_ok renew_ok recall_ok hold_ok inet)) {
$ilspatron{$toggle} = 0; # if we get noissues, disable everything
}
}
}
# FIXME: populate fine_items recall_items
$ilspatron{unavail_holds} = _get_outstanding_holds($kp->{borrowernumber});
my $pending_checkouts = $patron->pending_checkouts;
my @barcodes;
while ( my $c = $pending_checkouts->next ) {
push @barcodes, { barcode => $c->item->barcode };
}
$ilspatron{items} = \@barcodes;
$self = \%ilspatron;
siplog("LOG_DEBUG", "new ILS::Patron(%s): found patron '%s'", $patron_id,$self->{id});
bless $self, $type;
return $self;
}
# 0 means read-only
# 1 means read/write
my %fields = (
id => 0,
borrowernumber => 0,
name => 0,
address => 0,
email_addr => 0,
home_phone => 0,
birthdate => 0,
birthdate_iso => 0,
dateexpiry => 0,
dateexpiry_iso => 0,
debarred => 0,
fine_blocked => 0,
ptype => 0,
charge_ok => 0, # for patron_status[0] (inverted)
renew_ok => 0, # for patron_status[1] (inverted)
recall_ok => 0, # for patron_status[2] (inverted)
hold_ok => 0, # for patron_status[3] (inverted)
card_lost => 0, # for patron_status[4]
recall_overdue => 0,
currency => 1,
fee_limit => 0,
screen_msg => 1,
print_line => 1,
too_many_charged => 0, # for patron_status[5]
too_many_overdue => 0, # for patron_status[6]
too_many_renewal => 0, # for patron_status[7]
too_many_claim_return => 0, # for patron_status[8]
too_many_lost => 0, # for patron_status[9]
# excessive_fines => 0, # for patron_status[10]
# excessive_fees => 0, # for patron_status[11]
recall_overdue => 0, # for patron_status[12]
too_many_billed => 0, # for patron_status[13]
inet => 0, # EnvisionWare extension
);
our $AUTOLOAD;
sub DESTROY {
# be cool. needed for AUTOLOAD(?)
}
sub AUTOLOAD {
my $self = shift;
my $class = ref($self) or croak "$self is not an object";
my $name = $AUTOLOAD;
$name =~ s/.*://;
unless (exists $fields{$name}) {
croak "Cannot access '$name' field of class '$class'";
}
if (@_) {
$fields{$name} or croak "Field '$name' of class '$class' is READ ONLY.";
return $self->{$name} = shift;
} else {
return $self->{$name};
}
}
=head2 format
This method uses a template to build a string from a Koha::Patron object
If errors are encountered in processing template we log them and return nothing
=cut
sub format {
my ( $self, $template ) = @_;
if ($template) {
require Template;
require Koha::Patrons;
my $tt = Template->new();
my $patron = Koha::Patrons->find( $self->{borrowernumber} );
my $output;
eval {
$tt->process( \$template, { patron => $patron }, \$output );
};
if ( $@ ){
siplog("LOG_DEBUG", "Error processing template: $template");
return "";
}
return $output;
}
}
sub check_password {
my ( $self, $pwd ) = @_;
# you gotta give me something (at least ''), or no deal
return 0 unless defined $pwd;
# If the record has a NULL password, accept '' as match
return $pwd eq q{} unless $self->{password};
my $dbh = C4::Context->dbh;
my $ret = 0;
($ret) = checkpw( $dbh, $self->{userid}, $pwd, undef, undef, 1 ); # dbh, userid, query, type, no_set_userenv
return $ret;
}
# A few special cases, not in AUTOLOADed %fields
sub fee_amount {
my $self = shift;
if ( $self->{fines} ) {
return $self->{fines};
}
return 0;
}
sub fines_amount {
my $self = shift;
return $self->fee_amount;
}
sub language {
my $self = shift;
return $self->{language} || '000'; # Unspecified
}
sub expired {
my $self = shift;
return $self->{expired};
}
#
# remove the hold on item item_id from my hold queue.
# return true if I was holding the item, false otherwise.
#
sub drop_hold {
my ($self, $item_id) = @_;
return if !$item_id;
my $result = 0;
foreach (qw(hold_items unavail_holds)) {
$self->{$_} or next;
for (my $i = 0; $i < scalar @{$self->{$_}}; $i++) {
my $held_item = $self->{$_}[$i]->{barcode} or next;
if ($held_item eq $item_id) {
splice @{$self->{$_}}, $i, 1;
$result++;
}
}
}
return $result;
}
# Accessor method for array_ref values, designed to get the "start" and "end" values
# from the SIP request. Note those incoming values are 1-indexed, not 0-indexed.
#
sub x_items {
my $self = shift;
my $array_var = shift or return;
my ($start, $end) = @_;
my $item_list = [];
if ($self->{$array_var}) {
if ($start && $start > 1) {
--$start;
}
else {
$start = 0;
}
if ( $end && $end < @{$self->{$array_var}} ) {
}
else {
$end = @{$self->{$array_var}};
--$end;
}
@{$item_list} = @{$self->{$array_var}}[ $start .. $end ];
}
return $item_list;
}
#
# List of outstanding holds placed
#
sub hold_items {
my $self = shift;
my $item_arr = $self->x_items('hold_items', @_);
foreach my $item (@{$item_arr}) {
my $item_obj = Koha::Items->find($item->{itemnumber});
$item->{barcode} = $item_obj ? $item_obj->barcode : undef;
}
return $item_arr;
}
sub overdue_items {
my $self = shift;
return $self->x_items('overdue_items', @_);
}
sub charged_items {
my $self = shift;
return $self->x_items('items', @_);
}
sub fine_items {
require Koha::Database;
require Template;
my $self = shift;
my $start = shift;
my $end = shift;
my $server = shift;
my @fees = Koha::Database->new()->schema()->resultset('Accountline')->search(
{
borrowernumber => $self->{borrowernumber},
amountoutstanding => { '>' => '0' },
}
);
$start = $start ? $start - 1 : 0;
$end = $end ? $end - 1 : scalar @fees - 1;
my $av_field_template = $server ? $server->{account}->{av_field_template} : undef;
$av_field_template ||= "[% accountline.description %] [% accountline.amountoutstanding | format('%.2f') %]";
my $tt = Template->new();
my @return_values;
for ( my $i = $start; $i <= $end; $i++ ) {
my $fee = $fees[$i];
next unless $fee;
my $output;
$tt->process( \$av_field_template, { accountline => $fee }, \$output );
push( @return_values, { barcode => $output } );
}
return \@return_values;
}
sub recall_items {
my $self = shift;
return $self->x_items('recall_items', @_);
}
sub unavail_holds {
my $self = shift;
return $self->x_items('unavail_holds', @_);
}
sub block {
my ($self, $card_retained, $blocked_card_msg) = @_;
foreach my $field ('charge_ok', 'renew_ok', 'recall_ok', 'hold_ok', 'inet') {
$self->{$field} = 0;
}
$self->{screen_msg} = "Block feature not implemented"; # $blocked_card_msg || "Card Blocked. Please contact library staff";
# TODO: not really affecting patron record
return $self;
}
sub enable {
my $self = shift;
foreach my $field ('charge_ok', 'renew_ok', 'recall_ok', 'hold_ok', 'inet') {
$self->{$field} = 1;
}
siplog("LOG_DEBUG", "Patron(%s)->enable: charge: %s, renew:%s, recall:%s, hold:%s",
$self->{id}, $self->{charge_ok}, $self->{renew_ok},
$self->{recall_ok}, $self->{hold_ok});
$self->{screen_msg} = "Enable feature not implemented."; # "All privileges restored."; # TODO: not really affecting patron record
return $self;
}
sub inet_privileges {
my $self = shift;
return $self->{inet} ? 'Y' : 'N';
}
sub _fee_limit {
return C4::Context->preference('noissuescharge') || 5;
}
sub excessive_fees {
my $self = shift;
return ($self->fee_amount and $self->fee_amount > $self->fee_limit);
}
sub excessive_fines {
my $self = shift;
return $self->excessive_fees; # excessive_fines is the same thing as excessive_fees for Koha
}
sub holds_blocked_by_excessive_fees {
my $self = shift;
return ( $self->fee_amount
&& $self->fee_amount > C4::Context->preference("maxoutstanding") );
}
sub library_name {
my $self = shift;
unless ($self->{library_name}) {
my $library = Koha::Libraries->find( $self->{branchcode} );
$self->{library_name} = $library ? $library->branchname : '';
}
return $self->{library_name};
}
#
# Messages
#
sub invalid_patron {
my $self = shift;
return "Please contact library staff";
}
sub charge_denied {
my $self = shift;
return "Please contact library staff";
}
=head2 update_lastseen
$patron->update_lastseen();
Patron method to update lastseen field in borrower
to record that patron has been seen via sip connection
=cut
sub update_lastseen {
my $self = shift;
my $kohaobj = Koha::Patrons->find( $self->{borrowernumber} );
$kohaobj->track_login if $kohaobj; # track_login checks the pref
}
sub _get_address {
my $patron = shift;
my $address = $patron->{streetnumber} || q{};
for my $field (qw( roaddetails address address2 city state zipcode country))
{
next unless $patron->{$field};
if ($address) {
$address .= q{ };
$address .= $patron->{$field};
}
else {
$address .= $patron->{$field};
}
}
return $address;
}
sub _get_outstanding_holds {
my $borrowernumber = shift;
my $patron = Koha::Patrons->find( $borrowernumber );
my $holds = $patron->holds->search( { -or => [ { found => undef }, { found => { '!=' => 'W' } } ] } );
my @holds;
while ( my $hold = $holds->next ) {
my $item;
if ($hold->itemnumber) {
$item = $hold->item;
}
else {
# We need to return a barcode for the biblio so the client
# can request the biblio info
my $items = $hold->biblio->items;
$item = $items->count ? $items->next : undef;
}
my $unblessed_hold = $hold->unblessed;
$unblessed_hold->{barcode} = $item ? $item->barcode : undef;
push @holds, $unblessed_hold;
}
return \@holds;
}
=head2 build_patron_attributes_string
This method builds the part of the sip message for extended patron
attributes as defined in the sip config
=cut
sub build_patron_attributes_string {
my ( $self, $server ) = @_;
my $string = q{};
if ( $server->{account}->{patron_attribute} ) {
my @attributes_to_send =
ref $server->{account}->{patron_attribute} eq "ARRAY"
? @{ $server->{account}->{patron_attribute} }
: ( $server->{account}->{patron_attribute} );
foreach my $a ( @attributes_to_send ) {
my @attributes = Koha::Patron::Attributes->search(
{
borrowernumber => $self->{borrowernumber},
code => $a->{code}
}
)->as_list;
foreach my $attribute ( @attributes ) {
my $value = $attribute->attribute();
$string .= add_field( $a->{field}, $value );
}
}
}
return $string;
}
=head2 build_custom_field_string
This method builds the part of the sip message for custom patron fields as defined in the sip config
=cut
sub build_custom_field_string {
my ( $self, $server ) = @_;
my $string = q{};
if ( $server->{account}->{custom_patron_field} ) {
my @custom_fields =
ref $server->{account}->{custom_patron_field} eq "ARRAY"
? @{ $server->{account}->{custom_patron_field} }
: $server->{account}->{custom_patron_field};
foreach my $custom_field ( @custom_fields ) {
$string .= maybe_add( $custom_field->{field}, $self->format( $custom_field->{template} ) ) if defined $custom_field->{field};
}
}
return $string;
}
1;
__END__
=head1 EXAMPLES
our %patron_example = (
djfiander => {
name => "David J. Fiander",
id => 'djfiander',
password => '6789',
ptype => 'A', # 'A'dult. Whatever.
birthdate => '19640925',
address => '2 Meadowvale Dr. St Thomas, ON',
home_phone => '(519) 555 1234',
email_addr => 'djfiander@hotmail.com',
charge_ok => 1,
renew_ok => 1,
recall_ok => 0,
hold_ok => 1,
card_lost => 0,
claims_returned => 0,
fines => 100,
fees => 0,
recall_overdue => 0,
items_billed => 0,
screen_msg => '',
print_line => '',
items => [],
hold_items => [],
overdue_items => [],
fine_items => ['Computer Time'],
recall_items => [],
unavail_holds => [],
inet => 1,
},
);
From borrowers table:
+---------------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+---------------------+--------------+------+-----+---------+----------------+
| borrowernumber | int(11) | NO | PRI | NULL | auto_increment |
| cardnumber | varchar(16) | YES | UNI | NULL | |
| surname | mediumtext | NO | | NULL | |
| firstname | text | YES | | NULL | |
| title | mediumtext | YES | | NULL | |
| othernames | mediumtext | YES | | NULL | |
| initials | text | YES | | NULL | |
| streetnumber | varchar(10) | YES | | NULL | |
| streettype | varchar(50) | YES | | NULL | |
| address | mediumtext | NO | | NULL | |
| address2 | text | YES | | NULL | |
| city | mediumtext | NO | | NULL | |
| state | mediumtext | YES | | NULL | |
| zipcode | varchar(25) | YES | | NULL | |
| country | text | YES | | NULL | |
| email | mediumtext | YES | | NULL | |
| phone | text | YES | | NULL | |
| mobile | varchar(50) | YES | | NULL | |
| fax | mediumtext | YES | | NULL | |
| emailpro | text | YES | | NULL | |
| phonepro | text | YES | | NULL | |
| B_streetnumber | varchar(10) | YES | | NULL | |
| B_streettype | varchar(50) | YES | | NULL | |
| B_address | varchar(100) | YES | | NULL | |
| B_address2 | text | YES | | NULL | |
| B_city | mediumtext | YES | | NULL | |
| B_state | mediumtext | YES | | NULL | |
| B_zipcode | varchar(25) | YES | | NULL | |
| B_country | text | YES | | NULL | |
| B_email | text | YES | | NULL | |
| B_phone | mediumtext | YES | | NULL | |
| dateofbirth | date | YES | | NULL | |
| branchcode | varchar(10) | NO | MUL | | |
| categorycode | varchar(10) | NO | MUL | | |
| dateenrolled | date | YES | | NULL | |
| dateexpiry | date | YES | | NULL | |
| gonenoaddress | tinyint(1) | YES | | NULL | |
| lost | tinyint(1) | YES | | NULL | |
| debarred | tinyint(1) | YES | | NULL | |
| contactname | mediumtext | YES | | NULL | |
| contactfirstname | text | YES | | NULL | |
| contacttitle | text | YES | | NULL | |
| borrowernotes | mediumtext | YES | | NULL | |
| relationship | varchar(100) | YES | | NULL | |
| ethnicity | varchar(50) | YES | | NULL | |
| ethnotes | varchar(255) | YES | | NULL | |
| sex | varchar(1) | YES | | NULL | |
| password | varchar(30) | YES | | NULL | |
| flags | int(11) | YES | | NULL | |
| userid | varchar(30) | YES | MUL | NULL | |
| opacnote | mediumtext | YES | | NULL | |
| contactnote | varchar(255) | YES | | NULL | |
| sort1 | varchar(80) | YES | | NULL | |
| sort2 | varchar(80) | YES | | NULL | |
| altcontactfirstname | varchar(255) | YES | | NULL | |
| altcontactsurname | varchar(255) | YES | | NULL | |
| altcontactaddress1 | varchar(255) | YES | | NULL | |
| altcontactaddress2 | varchar(255) | YES | | NULL | |
| altcontactaddress3 | varchar(255) | YES | | NULL | |
| altcontactstate | mediumtext | YES | | NULL | |
| altcontactzipcode | varchar(50) | YES | | NULL | |
| altcontactcountry | text | YES | | NULL | |
| altcontactphone | varchar(50) | YES | | NULL | |
| smsalertnumber | varchar(50) | YES | | NULL | |
| privacy | int(11) | NO | | 1 | |
+---------------------+--------------+------+-----+---------+----------------+
From C4::Members
$flags->{KEY}
{CHARGES}
{message} Message showing patron's credit or debt
{noissues} Set if patron owes >$5.00
{GNA} Set if patron gone w/o address
{message} "Borrower has no valid address"
{noissues} Set.
{LOST} Set if patron's card reported lost
{message} Message to this effect
{noissues} Set.
{DBARRED} Set if patron is debarred
{message} Message to this effect
{noissues} Set.
{NOTES} Set if patron has notes
{message} Notes about patron
{ODUES} Set if patron has overdue books
{message} "Yes"
{itemlist} ref-to-array: list of overdue books
{itemlisttext} Text list of overdue items
{WAITING} Set if there are items available that the patron reserved
{message} Message to this effect
{itemlist} ref-to-array: list of available items
=cut