Tomas Cohen Arazi
554f791491
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io> Signed-off-by: David Nind <david@davidnind.com> Signed-off-by: Katrin Fischer <katrin.fischer.83@web.de> Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
2462 lines
73 KiB
Perl
2462 lines
73 KiB
Perl
package Koha::Item;
|
|
|
|
# Copyright ByWater Solutions 2014
|
|
#
|
|
# This file is part of Koha.
|
|
#
|
|
# Koha 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 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# Koha 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.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with Koha; if not, see <http://www.gnu.org/licenses>.
|
|
|
|
use Modern::Perl;
|
|
|
|
use List::MoreUtils qw( any );
|
|
use Try::Tiny qw( catch try );
|
|
|
|
use Koha::Database;
|
|
use Koha::DateUtils qw( dt_from_string output_pref );
|
|
|
|
use C4::Context;
|
|
use C4::Circulation qw( barcodedecode GetBranchItemRule );
|
|
use C4::Reserves;
|
|
use C4::ClassSource qw( GetClassSort );
|
|
use C4::Log qw( logaction );
|
|
|
|
use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue;
|
|
use Koha::Biblio::ItemGroups;
|
|
use Koha::Checkouts;
|
|
use Koha::CirculationRules;
|
|
use Koha::CoverImages;
|
|
use Koha::Exceptions;
|
|
use Koha::Exceptions::Checkin;
|
|
use Koha::Exceptions::Item::Bundle;
|
|
use Koha::Exceptions::Item::Transfer;
|
|
use Koha::Item::Attributes;
|
|
use Koha::Exceptions::Item::Bundle;
|
|
use Koha::Item::Transfer::Limits;
|
|
use Koha::Item::Transfers;
|
|
use Koha::ItemTypes;
|
|
use Koha::Libraries;
|
|
use Koha::Patrons;
|
|
use Koha::Plugins;
|
|
use Koha::Recalls;
|
|
use Koha::Result::Boolean;
|
|
use Koha::SearchEngine::Indexer;
|
|
use Koha::StockRotationItem;
|
|
use Koha::StockRotationRotas;
|
|
use Koha::TrackedLinks;
|
|
use Koha::Policy::Holds;
|
|
|
|
use base qw(Koha::Object);
|
|
|
|
=head1 NAME
|
|
|
|
Koha::Item - Koha Item object class
|
|
|
|
=head1 API
|
|
|
|
=head2 Class methods
|
|
|
|
=cut
|
|
|
|
=head3 store
|
|
|
|
$item->store;
|
|
|
|
$params can take an optional 'skip_record_index' parameter.
|
|
If set, the reindexation process will not happen (index_records not called)
|
|
You should not turn it on if you do not understand what it is doing exactly.
|
|
|
|
=cut
|
|
|
|
sub store {
|
|
my $self = shift;
|
|
my $params = @_ ? shift : {};
|
|
|
|
my $log_action = $params->{log_action} // 1;
|
|
|
|
# We do not want to oblige callers to pass this value
|
|
# Dev conveniences vs performance?
|
|
unless ( $self->biblioitemnumber ) {
|
|
$self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
|
|
}
|
|
|
|
# See related changes from C4::Items::AddItem
|
|
unless ( $self->itype ) {
|
|
$self->itype($self->biblio->biblioitem->itemtype);
|
|
}
|
|
|
|
$self->barcode( C4::Circulation::barcodedecode( $self->barcode ) );
|
|
|
|
my $today = dt_from_string;
|
|
my $action = 'create';
|
|
|
|
unless ( $self->in_storage ) { #AddItem
|
|
|
|
unless ( $self->permanent_location ) {
|
|
$self->permanent_location($self->location);
|
|
}
|
|
|
|
my $default_location = C4::Context->preference('NewItemsDefaultLocation');
|
|
unless ( $self->location || !$default_location ) {
|
|
$self->permanent_location( $self->location || $default_location )
|
|
unless $self->permanent_location;
|
|
$self->location($default_location);
|
|
}
|
|
|
|
unless ( $self->replacementpricedate ) {
|
|
$self->replacementpricedate($today);
|
|
}
|
|
unless ( $self->datelastseen ) {
|
|
$self->datelastseen($today);
|
|
}
|
|
|
|
unless ( $self->dateaccessioned ) {
|
|
$self->dateaccessioned($today);
|
|
}
|
|
|
|
if ( $self->itemcallnumber
|
|
or $self->cn_source )
|
|
{
|
|
my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
|
|
$self->cn_sort($cn_sort);
|
|
}
|
|
|
|
# should be quite rare when adding item
|
|
if ( $self->itemlost && $self->itemlost > 0 ) { # TODO BZ34308
|
|
$self->_add_statistic('item_lost');
|
|
}
|
|
|
|
} else { # ModItem
|
|
|
|
$action = 'modify';
|
|
|
|
my %updated_columns = $self->_result->get_dirty_columns;
|
|
return $self->SUPER::store unless %updated_columns;
|
|
|
|
# Retrieve the item for comparison if we need to
|
|
my $pre_mod_item = (
|
|
exists $updated_columns{itemlost}
|
|
or exists $updated_columns{withdrawn}
|
|
or exists $updated_columns{damaged}
|
|
) ? $self->get_from_storage : undef;
|
|
|
|
# Update *_on fields if needed
|
|
# FIXME: Why not for AddItem as well?
|
|
my @fields = qw( itemlost withdrawn damaged );
|
|
for my $field (@fields) {
|
|
|
|
# If the field is defined but empty or 0, we are
|
|
# removing/unsetting and thus need to clear out
|
|
# the 'on' field
|
|
if ( exists $updated_columns{$field}
|
|
&& defined( $self->$field )
|
|
&& !$self->$field )
|
|
{
|
|
my $field_on = "${field}_on";
|
|
$self->$field_on(undef);
|
|
}
|
|
# If the field has changed otherwise, we much update
|
|
# the 'on' field
|
|
elsif (exists $updated_columns{$field}
|
|
&& $updated_columns{$field}
|
|
&& !$pre_mod_item->$field )
|
|
{
|
|
my $field_on = "${field}_on";
|
|
$self->$field_on(dt_from_string);
|
|
}
|
|
}
|
|
|
|
if ( exists $updated_columns{itemcallnumber}
|
|
or exists $updated_columns{cn_source} )
|
|
{
|
|
my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
|
|
$self->cn_sort($cn_sort);
|
|
}
|
|
|
|
|
|
if ( exists $updated_columns{location}
|
|
and ( !defined($self->location) or $self->location !~ /^(CART|PROC)$/ )
|
|
and not exists $updated_columns{permanent_location} )
|
|
{
|
|
$self->permanent_location( $self->location );
|
|
}
|
|
|
|
# TODO BZ 34308 (gt zero checks)
|
|
if ( exists $updated_columns{itemlost}
|
|
&& ( !$updated_columns{itemlost} || $updated_columns{itemlost} <= 0 )
|
|
&& ( $pre_mod_item->itemlost && $pre_mod_item->itemlost > 0 ) )
|
|
{
|
|
# item found
|
|
# reverse any list item charges if necessary
|
|
$self->_set_found_trigger($pre_mod_item);
|
|
$self->_add_statistic('item_found');
|
|
} elsif ( exists $updated_columns{itemlost}
|
|
&& ( $updated_columns{itemlost} && $updated_columns{itemlost} > 0 )
|
|
&& ( !$pre_mod_item->itemlost || $pre_mod_item->itemlost <= 0 ) )
|
|
{
|
|
# item lost
|
|
$self->_add_statistic('item_lost');
|
|
}
|
|
}
|
|
|
|
my $result = $self->SUPER::store;
|
|
if ( $log_action && C4::Context->preference("CataloguingLog") ) {
|
|
$action eq 'create'
|
|
? logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
|
|
: logaction( "CATALOGUING", "MODIFY", $self->itemnumber, $self );
|
|
}
|
|
my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
|
|
$indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
|
|
unless $params->{skip_record_index};
|
|
$self->get_from_storage->_after_item_action_hooks({ action => $action });
|
|
|
|
Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
|
|
{
|
|
biblio_ids => [ $self->biblionumber ]
|
|
}
|
|
) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
|
|
|
|
return $result;
|
|
}
|
|
|
|
sub _add_statistic {
|
|
my ( $self, $type ) = @_;
|
|
C4::Stats::UpdateStats(
|
|
{
|
|
borrowernumber => undef,
|
|
branch => C4::Context->userenv ? C4::Context->userenv->{branch} : undef,
|
|
categorycode => undef,
|
|
ccode => $self->ccode,
|
|
itemnumber => $self->itemnumber,
|
|
itemtype => $self->effective_itemtype,
|
|
location => $self->location,
|
|
type => $type,
|
|
}
|
|
);
|
|
}
|
|
|
|
=head3 delete
|
|
|
|
=cut
|
|
|
|
sub delete {
|
|
my $self = shift;
|
|
my $params = @_ ? shift : {};
|
|
|
|
# FIXME check the item has no current issues
|
|
# i.e. raise the appropriate exception
|
|
|
|
# Get the item group so we can delete it later if it has no items left
|
|
my $item_group = C4::Context->preference('EnableItemGroups') ? $self->item_group : undef;
|
|
|
|
my $result = $self->SUPER::delete;
|
|
|
|
# Delete the item group if it has no items left
|
|
$item_group->delete if ( $item_group && $item_group->items->count == 0 );
|
|
|
|
my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
|
|
$indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
|
|
unless $params->{skip_record_index};
|
|
|
|
$self->_after_item_action_hooks({ action => 'delete' });
|
|
|
|
logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
|
|
if C4::Context->preference("CataloguingLog");
|
|
|
|
Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
|
|
{
|
|
biblio_ids => [ $self->biblionumber ]
|
|
}
|
|
) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
|
|
|
|
return $result;
|
|
}
|
|
|
|
=head3 safe_delete
|
|
|
|
=cut
|
|
|
|
sub safe_delete {
|
|
my $self = shift;
|
|
my $params = @_ ? shift : {};
|
|
|
|
my $safe_to_delete = $self->safe_to_delete;
|
|
return $safe_to_delete unless $safe_to_delete;
|
|
|
|
$self->move_to_deleted;
|
|
|
|
return $self->delete($params);
|
|
}
|
|
|
|
=head3 safe_to_delete
|
|
|
|
returns 1 if the item is safe to delete,
|
|
|
|
"book_on_loan" if the item is checked out,
|
|
|
|
"not_same_branch" if the item is blocked by independent branches,
|
|
|
|
"book_reserved" if the there are holds aganst the item, or
|
|
|
|
"linked_analytics" if the item has linked analytic records.
|
|
|
|
"last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
|
|
|
|
=cut
|
|
|
|
sub safe_to_delete {
|
|
my ($self) = @_;
|
|
|
|
my $error;
|
|
|
|
$error = "book_on_loan" if $self->checkout;
|
|
|
|
$error //= "not_same_branch"
|
|
if defined C4::Context->userenv
|
|
and defined C4::Context->userenv->{number}
|
|
and !Koha::Patrons->find( C4::Context->userenv->{number} )->can_edit_items_from( $self->homebranch );
|
|
|
|
# check it doesn't have a waiting reserve
|
|
$error //= "book_reserved"
|
|
if $self->holds->filter_by_found->count;
|
|
|
|
$error //= "linked_analytics"
|
|
if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
|
|
|
|
$error //= "last_item_for_hold"
|
|
if $self->biblio->items->count == 1
|
|
&& $self->biblio->holds->search(
|
|
{
|
|
itemnumber => undef,
|
|
}
|
|
)->count;
|
|
|
|
if ( $error ) {
|
|
return Koha::Result::Boolean->new(0)->add_message({ message => $error });
|
|
}
|
|
|
|
return Koha::Result::Boolean->new(1);
|
|
}
|
|
|
|
=head3 move_to_deleted
|
|
|
|
my $is_moved = $item->move_to_deleted;
|
|
|
|
Move an item to the deleteditems table.
|
|
This can be done before deleting an item, to make sure the data are not completely deleted.
|
|
|
|
=cut
|
|
|
|
sub move_to_deleted {
|
|
my ($self) = @_;
|
|
my $item_infos = $self->unblessed;
|
|
delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
|
|
$item_infos->{deleted_on} = dt_from_string;
|
|
return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
|
|
}
|
|
|
|
=head3 effective_itemtype
|
|
|
|
Returns the itemtype for the item based on whether item level itemtypes are set or not.
|
|
|
|
=cut
|
|
|
|
sub effective_itemtype {
|
|
my ( $self ) = @_;
|
|
|
|
return $self->_result()->effective_itemtype();
|
|
}
|
|
|
|
=head3 home_branch
|
|
|
|
=cut
|
|
|
|
sub home_branch {
|
|
my ($self) = @_;
|
|
|
|
my $hb_rs = $self->_result->homebranch;
|
|
|
|
return Koha::Library->_new_from_dbic( $hb_rs );
|
|
}
|
|
|
|
=head3 holding_branch
|
|
|
|
=cut
|
|
|
|
sub holding_branch {
|
|
my ($self) = @_;
|
|
|
|
my $hb_rs = $self->_result->holdingbranch;
|
|
|
|
return Koha::Library->_new_from_dbic( $hb_rs );
|
|
}
|
|
|
|
=head3 biblio
|
|
|
|
my $biblio = $item->biblio;
|
|
|
|
Return the bibliographic record of this item
|
|
|
|
=cut
|
|
|
|
sub biblio {
|
|
my ( $self ) = @_;
|
|
my $biblio_rs = $self->_result->biblio;
|
|
return Koha::Biblio->_new_from_dbic( $biblio_rs );
|
|
}
|
|
|
|
=head3 biblioitem
|
|
|
|
my $biblioitem = $item->biblioitem;
|
|
|
|
Return the biblioitem record of this item
|
|
|
|
=cut
|
|
|
|
sub biblioitem {
|
|
my ( $self ) = @_;
|
|
my $biblioitem_rs = $self->_result->biblioitem;
|
|
return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
|
|
}
|
|
|
|
=head3 checkout
|
|
|
|
my $checkout = $item->checkout;
|
|
|
|
Return the checkout for this item
|
|
|
|
=cut
|
|
|
|
sub checkout {
|
|
my ( $self ) = @_;
|
|
my $checkout_rs = $self->_result->issue;
|
|
return unless $checkout_rs;
|
|
return Koha::Checkout->_new_from_dbic( $checkout_rs );
|
|
}
|
|
|
|
=head3 item_group
|
|
|
|
my $item_group = $item->item_group;
|
|
|
|
Return the item group for this item
|
|
|
|
=cut
|
|
|
|
sub item_group {
|
|
my ( $self ) = @_;
|
|
|
|
my $item_group_item = $self->_result->item_group_item;
|
|
return unless $item_group_item;
|
|
|
|
my $item_group_rs = $item_group_item->item_group;
|
|
return unless $item_group_rs;
|
|
|
|
my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs );
|
|
return $item_group;
|
|
}
|
|
|
|
=head3 return_claims
|
|
|
|
my $return_claims = $item->return_claims;
|
|
|
|
Return any return_claims associated with this item
|
|
|
|
=cut
|
|
|
|
sub return_claims {
|
|
my ( $self, $params, $attrs ) = @_;
|
|
my $claims_rs = $self->_result->return_claims->search($params, $attrs);
|
|
return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs );
|
|
}
|
|
|
|
=head3 return_claim
|
|
|
|
my $return_claim = $item->return_claim;
|
|
|
|
Returns the most recent unresolved return_claims associated with this item
|
|
|
|
=cut
|
|
|
|
sub return_claim {
|
|
my ($self) = @_;
|
|
my $claims_rs =
|
|
$self->_result->return_claims->search( { resolution => undef },
|
|
{ order_by => { '-desc' => 'created_on' }, rows => 1 } )->single;
|
|
return unless $claims_rs;
|
|
return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs);
|
|
}
|
|
|
|
=head3 holds
|
|
|
|
my $holds = $item->holds();
|
|
my $holds = $item->holds($params);
|
|
my $holds = $item->holds({ found => 'W'});
|
|
|
|
Return holds attached to an item, optionally accept a hashref of params to pass to search
|
|
|
|
=cut
|
|
|
|
sub holds {
|
|
my ( $self,$params ) = @_;
|
|
my $holds_rs = $self->_result->reserves->search($params);
|
|
return Koha::Holds->_new_from_dbic( $holds_rs );
|
|
}
|
|
|
|
=head3 bookings
|
|
|
|
my $bookings = $item->bookings();
|
|
|
|
Returns the bookings attached to this item.
|
|
|
|
=cut
|
|
|
|
sub bookings {
|
|
my ( $self, $params ) = @_;
|
|
my $bookings_rs = $self->_result->bookings->search($params);
|
|
return Koha::Bookings->_new_from_dbic($bookings_rs);
|
|
}
|
|
|
|
=head3 find_booking
|
|
|
|
Find the first booking that would conflict with the passed checkout dates
|
|
|
|
=cut
|
|
|
|
sub find_booking {
|
|
my ( $self, $params ) = @_;
|
|
|
|
my $checkout_date = $params->{checkout_date};
|
|
my $due_date = $params->{due_date};
|
|
my $biblio = $self->biblio;
|
|
|
|
my $dtf = Koha::Database->new->schema->storage->datetime_parser;
|
|
my $bookings = $biblio->bookings(
|
|
[
|
|
# Checkout starts during booked period
|
|
start_date => {
|
|
'-between' => [
|
|
$dtf->format_datetime($checkout_date),
|
|
$dtf->format_datetime($due_date)
|
|
]
|
|
},
|
|
|
|
# Checkout is due during booked period
|
|
end_date => {
|
|
'-between' => [
|
|
$dtf->format_datetime($checkout_date),
|
|
$dtf->format_datetime($due_date)
|
|
]
|
|
},
|
|
|
|
# Checkout contains booked period
|
|
{
|
|
start_date => { '<' => $dtf->format_datetime($checkout_date) },
|
|
end_date => { '>' => $dtf->format_datetime($due_date) }
|
|
}
|
|
],
|
|
{ order_by => { '-asc' => 'start_date' } }
|
|
);
|
|
|
|
my $checkouts = {};
|
|
my $loanable_items = {};
|
|
my $bookable_items = $biblio->bookable_items;
|
|
while ( my $item = $bookable_items->next ) {
|
|
$loanable_items->{ $item->itemnumber } = 1;
|
|
if ( my $checkout = $item->checkout ) {
|
|
$checkouts->{ $item->itemnumber } = dt_from_string( $checkout->date_due );
|
|
}
|
|
}
|
|
|
|
while ( my $booking = $bookings->next ) {
|
|
|
|
# Booking for this item
|
|
if ( defined( $booking->item_id )
|
|
&& $booking->item_id == $self->itemnumber )
|
|
{
|
|
return $booking;
|
|
}
|
|
|
|
# Booking for another item
|
|
elsif ( defined( $booking->item_id ) ) {
|
|
# Due for another booking, remove from pool
|
|
delete $loanable_items->{ $booking->item_id };
|
|
next;
|
|
|
|
}
|
|
|
|
# Booking for any item
|
|
else {
|
|
# Can another item satisfy this booking?
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
=head3 check_booking
|
|
|
|
my $bookable =
|
|
$item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
|
|
|
|
Returns a boolean denoting whether the passed booking can be made without clashing.
|
|
|
|
Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
|
|
|
|
=cut
|
|
|
|
sub check_booking {
|
|
my ( $self, $params ) = @_;
|
|
|
|
my $start_date = dt_from_string( $params->{start_date} );
|
|
my $end_date = dt_from_string( $params->{end_date} );
|
|
my $booking_id = $params->{booking_id};
|
|
|
|
if ( my $checkout = $self->checkout ) {
|
|
return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
|
|
}
|
|
|
|
my $dtf = Koha::Database->new->schema->storage->datetime_parser;
|
|
|
|
my $existing_bookings = $self->bookings(
|
|
[
|
|
start_date => {
|
|
'-between' => [
|
|
$dtf->format_datetime($start_date),
|
|
$dtf->format_datetime($end_date)
|
|
]
|
|
},
|
|
end_date => {
|
|
'-between' => [
|
|
$dtf->format_datetime($start_date),
|
|
$dtf->format_datetime($end_date)
|
|
]
|
|
},
|
|
{
|
|
start_date => { '<' => $dtf->format_datetime($start_date) },
|
|
end_date => { '>' => $dtf->format_datetime($end_date) }
|
|
}
|
|
]
|
|
);
|
|
|
|
my $bookings_count =
|
|
defined($booking_id)
|
|
? $existing_bookings->search( { booking_id => { '!=' => $booking_id } } )->count
|
|
: $existing_bookings->count;
|
|
|
|
return $bookings_count ? 0 : 1;
|
|
}
|
|
|
|
=head3 place_booking
|
|
|
|
my $booking = $item->place_booking(
|
|
{
|
|
patron => $patron,
|
|
start_date => $datetime,
|
|
end_date => $datetime
|
|
}
|
|
);
|
|
|
|
Add a booking for this item for the dates passed.
|
|
|
|
Returns the Koha::Booking object or throws an exception if the item cannot be booked for the given dates.
|
|
|
|
=cut
|
|
|
|
sub place_booking {
|
|
my ( $self, $params ) = @_;
|
|
|
|
# check for mandatory params
|
|
my @mandatory = ( 'start_date', 'end_date', 'patron' );
|
|
for my $param (@mandatory) {
|
|
unless ( defined( $params->{$param} ) ) {
|
|
Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
|
|
}
|
|
}
|
|
my $patron = $params->{patron};
|
|
|
|
# New booking object
|
|
my $booking = Koha::Booking->new(
|
|
{
|
|
start_date => $params->{start_date},
|
|
end_date => $params->{end_date},
|
|
patron_id => $patron->borrowernumber,
|
|
biblio_id => $self->biblionumber,
|
|
item_id => $self->itemnumber,
|
|
}
|
|
)->store();
|
|
return $booking;
|
|
}
|
|
|
|
=head3 request_transfer
|
|
|
|
my $transfer = $item->request_transfer(
|
|
{
|
|
to => $to_library,
|
|
reason => $reason,
|
|
[ ignore_limits => 0, enqueue => 1, replace => 1 ]
|
|
}
|
|
);
|
|
|
|
Add a transfer request for this item to the given branch for the given reason.
|
|
|
|
An exception will be thrown if the BranchTransferLimits would prevent the requested
|
|
transfer, unless 'ignore_limits' is passed to override the limits.
|
|
|
|
An exception will be thrown if an active transfer (i.e pending arrival date) is found;
|
|
The caller should catch such cases and retry the transfer request as appropriate passing
|
|
an appropriate override.
|
|
|
|
Overrides
|
|
* enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
|
|
* replace - Used to replace the existing transfer request with your own.
|
|
|
|
=cut
|
|
|
|
sub request_transfer {
|
|
my ( $self, $params ) = @_;
|
|
|
|
# check for mandatory params
|
|
my @mandatory = ( 'to', 'reason' );
|
|
for my $param (@mandatory) {
|
|
unless ( defined( $params->{$param} ) ) {
|
|
Koha::Exceptions::MissingParameter->throw(
|
|
error => "The $param parameter is mandatory" );
|
|
}
|
|
}
|
|
|
|
Koha::Exceptions::Item::Transfer::Limit->throw()
|
|
unless ( $params->{ignore_limits}
|
|
|| $self->can_be_transferred( { to => $params->{to} } ) );
|
|
|
|
my $request = $self->get_transfer;
|
|
Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
|
|
if ( $request && !$params->{enqueue} && !$params->{replace} );
|
|
|
|
$request->cancel( { reason => $params->{reason}, force => 1 } )
|
|
if ( defined($request) && $params->{replace} );
|
|
|
|
my $transfer = Koha::Item::Transfer->new(
|
|
{
|
|
itemnumber => $self->itemnumber,
|
|
daterequested => dt_from_string,
|
|
frombranch => $self->holdingbranch,
|
|
tobranch => $params->{to}->branchcode,
|
|
reason => $params->{reason},
|
|
comments => $params->{comment}
|
|
}
|
|
)->store();
|
|
|
|
return $transfer;
|
|
}
|
|
|
|
=head3 get_transfer
|
|
|
|
my $transfer = $item->get_transfer;
|
|
|
|
Return the active transfer request or undef
|
|
|
|
Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
|
|
whereby the most recently sent, but not received, transfer will be returned
|
|
if it exists, otherwise the oldest unsatisfied transfer will be returned.
|
|
|
|
This allows for transfers to queue, which is the case for stock rotation and
|
|
rotating collections where a manual transfer may need to take precedence but
|
|
we still expect the item to end up at a final location eventually.
|
|
|
|
=cut
|
|
|
|
sub get_transfer {
|
|
my ($self) = @_;
|
|
|
|
my $transfer = $self->_result->current_branchtransfers->next;
|
|
return Koha::Item::Transfer->_new_from_dbic($transfer) if $transfer;
|
|
}
|
|
|
|
=head3 get_transfers
|
|
|
|
my $transfer = $item->get_transfers;
|
|
|
|
Return the list of outstanding transfers (i.e requested but not yet cancelled
|
|
or received).
|
|
|
|
Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
|
|
whereby the most recently sent, but not received, transfer will be returned
|
|
first if it exists, otherwise requests are in oldest to newest request order.
|
|
|
|
This allows for transfers to queue, which is the case for stock rotation and
|
|
rotating collections where a manual transfer may need to take precedence but
|
|
we still expect the item to end up at a final location eventually.
|
|
|
|
=cut
|
|
|
|
sub get_transfers {
|
|
my ($self) = @_;
|
|
|
|
my $transfer_rs = $self->_result->current_branchtransfers;
|
|
|
|
return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
|
|
}
|
|
|
|
=head3 last_returned_by
|
|
|
|
Gets and sets the last patron to return an item.
|
|
|
|
Accepts a patron's id (borrowernumber) and returns Koha::Patron objects
|
|
|
|
$item->last_returned_by( $borrowernumber );
|
|
|
|
my $patron = $item->last_returned_by();
|
|
|
|
=cut
|
|
|
|
sub last_returned_by {
|
|
my ( $self, $borrowernumber ) = @_;
|
|
if ( $borrowernumber ) {
|
|
$self->_result->update_or_create_related('last_returned_by',
|
|
{ borrowernumber => $borrowernumber, itemnumber => $self->itemnumber } );
|
|
}
|
|
my $rs = $self->_result->last_returned_by;
|
|
return unless $rs;
|
|
return Koha::Patron->_new_from_dbic($rs->borrowernumber);
|
|
}
|
|
|
|
=head3 can_article_request
|
|
|
|
my $bool = $item->can_article_request( $borrower )
|
|
|
|
Returns true if item can be specifically requested
|
|
|
|
$borrower must be a Koha::Patron object
|
|
|
|
=cut
|
|
|
|
sub can_article_request {
|
|
my ( $self, $borrower ) = @_;
|
|
|
|
my $rule = $self->article_request_type($borrower);
|
|
|
|
return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
|
|
return q{};
|
|
}
|
|
|
|
=head3 hidden_in_opac
|
|
|
|
my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
|
|
|
|
Returns true if item fields match the hidding criteria defined in $rules.
|
|
Returns false otherwise.
|
|
|
|
Takes HASHref that can have the following parameters:
|
|
OPTIONAL PARAMETERS:
|
|
$rules : { <field> => [ value_1, ... ], ... }
|
|
|
|
Note: $rules inherits its structure from the parsed YAML from reading
|
|
the I<OpacHiddenItems> system preference.
|
|
|
|
=cut
|
|
|
|
sub hidden_in_opac {
|
|
my ( $self, $params ) = @_;
|
|
|
|
my $rules = $params->{rules} // {};
|
|
|
|
return 1
|
|
if C4::Context->preference('hidelostitems') and
|
|
$self->itemlost > 0;
|
|
|
|
my $hidden_in_opac = 0;
|
|
|
|
foreach my $field ( keys %{$rules} ) {
|
|
|
|
if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
|
|
$hidden_in_opac = 1;
|
|
last;
|
|
}
|
|
}
|
|
|
|
return $hidden_in_opac;
|
|
}
|
|
|
|
=head3 can_be_transferred
|
|
|
|
$item->can_be_transferred({ to => $to_library, from => $from_library })
|
|
Checks if an item can be transferred to given library.
|
|
|
|
This feature is controlled by two system preferences:
|
|
UseBranchTransferLimits to enable / disable the feature
|
|
BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
|
|
for setting the limitations
|
|
|
|
Takes HASHref that can have the following parameters:
|
|
MANDATORY PARAMETERS:
|
|
$to : Koha::Library
|
|
OPTIONAL PARAMETERS:
|
|
$from : Koha::Library # if not given, item holdingbranch
|
|
# will be used instead
|
|
|
|
Returns 1 if item can be transferred to $to_library, otherwise 0.
|
|
|
|
To find out whether at least one item of a Koha::Biblio can be transferred, please
|
|
see Koha::Biblio->can_be_transferred() instead of using this method for
|
|
multiple items of the same biblio.
|
|
|
|
=cut
|
|
|
|
sub can_be_transferred {
|
|
my ($self, $params) = @_;
|
|
|
|
my $to = $params->{to};
|
|
my $from = $params->{from};
|
|
|
|
$to = $to->branchcode;
|
|
$from = defined $from ? $from->branchcode : $self->holdingbranch;
|
|
|
|
return 1 if $from eq $to; # Transfer to current branch is allowed
|
|
return 1 unless C4::Context->preference('UseBranchTransferLimits');
|
|
|
|
my $limittype = C4::Context->preference('BranchTransferLimitsType');
|
|
return Koha::Item::Transfer::Limits->search({
|
|
toBranch => $to,
|
|
fromBranch => $from,
|
|
$limittype => $limittype eq 'itemtype'
|
|
? $self->effective_itemtype : $self->ccode
|
|
})->count ? 0 : 1;
|
|
|
|
}
|
|
|
|
=head3 pickup_locations
|
|
|
|
my $pickup_locations = $item->pickup_locations({ patron => $patron })
|
|
|
|
Returns possible pickup locations for this item, according to patron's home library
|
|
and if item can be transferred to each pickup location.
|
|
|
|
Throws a I<Koha::Exceptions::MissingParameter> exception if the B<mandatory> parameter I<patron>
|
|
is not passed.
|
|
|
|
=cut
|
|
|
|
sub pickup_locations {
|
|
my ($self, $params) = @_;
|
|
|
|
Koha::Exceptions::MissingParameter->throw( parameter => 'patron' )
|
|
unless exists $params->{patron};
|
|
|
|
my $patron = $params->{patron};
|
|
|
|
my $circ_control_branch = Koha::Policy::Holds->holds_control_library( $self, $patron );
|
|
my $branchitemrule =
|
|
C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
|
|
|
|
return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
|
|
return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
|
|
|
|
my $pickup_libraries = Koha::Libraries->search();
|
|
if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
|
|
$pickup_libraries = $self->home_branch->get_hold_libraries;
|
|
} elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
|
|
my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
|
|
$pickup_libraries = $plib->get_hold_libraries;
|
|
} elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
|
|
$pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
|
|
} elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
|
|
$pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
|
|
};
|
|
|
|
return $pickup_libraries->search(
|
|
{
|
|
pickup_location => 1
|
|
},
|
|
{
|
|
order_by => ['branchname']
|
|
}
|
|
) unless C4::Context->preference('UseBranchTransferLimits');
|
|
|
|
my $limittype = C4::Context->preference('BranchTransferLimitsType');
|
|
my ($ccode, $itype) = (undef, undef);
|
|
if( $limittype eq 'ccode' ){
|
|
$ccode = $self->ccode;
|
|
} else {
|
|
$itype = $self->itype;
|
|
}
|
|
my $limits = Koha::Item::Transfer::Limits->search(
|
|
{
|
|
fromBranch => $self->holdingbranch,
|
|
ccode => $ccode,
|
|
itemtype => $itype,
|
|
},
|
|
{ columns => ['toBranch'] }
|
|
);
|
|
|
|
return $pickup_libraries->search(
|
|
{
|
|
pickup_location => 1,
|
|
branchcode => {
|
|
'-not_in' => $limits->_resultset->as_query
|
|
}
|
|
},
|
|
{
|
|
order_by => ['branchname']
|
|
}
|
|
);
|
|
}
|
|
|
|
=head3 article_request_type
|
|
|
|
my $type = $item->article_request_type( $borrower )
|
|
|
|
returns 'yes', 'no', 'bib_only', or 'item_only'
|
|
|
|
$borrower must be a Koha::Patron object
|
|
|
|
=cut
|
|
|
|
sub article_request_type {
|
|
my ( $self, $borrower ) = @_;
|
|
|
|
my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
|
|
my $branchcode =
|
|
$branch_control eq 'homebranch' ? $self->homebranch
|
|
: $branch_control eq 'holdingbranch' ? $self->holdingbranch
|
|
: undef;
|
|
my $borrowertype = $borrower->categorycode;
|
|
my $itemtype = $self->effective_itemtype();
|
|
my $rule = Koha::CirculationRules->get_effective_rule(
|
|
{
|
|
rule_name => 'article_requests',
|
|
categorycode => $borrowertype,
|
|
itemtype => $itemtype,
|
|
branchcode => $branchcode
|
|
}
|
|
);
|
|
|
|
return q{} unless $rule;
|
|
return $rule->rule_value || q{}
|
|
}
|
|
|
|
=head3 current_holds
|
|
|
|
=cut
|
|
|
|
sub current_holds {
|
|
my ( $self ) = @_;
|
|
my $attributes = { order_by => 'priority' };
|
|
my $dtf = Koha::Database->new->schema->storage->datetime_parser;
|
|
my $params = {
|
|
itemnumber => $self->itemnumber,
|
|
suspend => 0,
|
|
-or => [
|
|
reservedate => { '<=' => $dtf->format_date(dt_from_string) },
|
|
waitingdate => { '!=' => undef },
|
|
],
|
|
};
|
|
my $hold_rs = $self->_result->reserves->search( $params, $attributes );
|
|
return Koha::Holds->_new_from_dbic($hold_rs);
|
|
}
|
|
|
|
=head3 stockrotationitem
|
|
|
|
my $sritem = Koha::Item->stockrotationitem;
|
|
|
|
Returns the stock rotation item associated with the current item.
|
|
|
|
=cut
|
|
|
|
sub stockrotationitem {
|
|
my ( $self ) = @_;
|
|
my $rs = $self->_result->stockrotationitem;
|
|
return 0 if !$rs;
|
|
return Koha::StockRotationItem->_new_from_dbic( $rs );
|
|
}
|
|
|
|
=head3 add_to_rota
|
|
|
|
my $item = $item->add_to_rota($rota_id);
|
|
|
|
Add this item to the rota identified by $ROTA_ID, which means associating it
|
|
with the first stage of that rota. Should this item already be associated
|
|
with a rota, then we will move it to the new rota.
|
|
|
|
=cut
|
|
|
|
sub add_to_rota {
|
|
my ( $self, $rota_id ) = @_;
|
|
Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
|
|
return $self;
|
|
}
|
|
|
|
=head3 has_pending_hold
|
|
|
|
my $is_pending_hold = $item->has_pending_hold();
|
|
|
|
This method checks the tmp_holdsqueue to see if this item has been selected for a hold, but not filled yet and returns true or false
|
|
|
|
=cut
|
|
|
|
sub has_pending_hold {
|
|
my ($self) = @_;
|
|
return $self->_result->tmp_holdsqueue ? 1 : 0;
|
|
}
|
|
|
|
=head3 has_pending_recall {
|
|
|
|
my $has_pending_recall
|
|
|
|
Return if whether has pending recall of not.
|
|
|
|
=cut
|
|
|
|
sub has_pending_recall {
|
|
my ( $self ) = @_;
|
|
|
|
# FIXME Must be moved to $self->recalls
|
|
return Koha::Recalls->search(
|
|
{
|
|
item_id => $self->itemnumber,
|
|
status => 'waiting',
|
|
}
|
|
)->count;
|
|
}
|
|
|
|
=head3 as_marc_field
|
|
|
|
my $field = $item->as_marc_field;
|
|
|
|
This method returns a MARC::Field object representing the Koha::Item object
|
|
with the current mappings configuration.
|
|
|
|
=cut
|
|
|
|
sub as_marc_field {
|
|
my ( $self ) = @_;
|
|
|
|
my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
|
|
|
|
my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
|
|
|
|
my @subfields;
|
|
|
|
my $item_field = $tagslib->{$itemtag};
|
|
|
|
my $more_subfields = $self->additional_attributes->to_hashref;
|
|
foreach my $subfield (
|
|
sort {
|
|
$a->{display_order} <=> $b->{display_order}
|
|
|| $a->{subfield} cmp $b->{subfield}
|
|
} grep { ref($_) && %$_ } values %$item_field
|
|
){
|
|
|
|
my $kohafield = $subfield->{kohafield};
|
|
my $tagsubfield = $subfield->{tagsubfield};
|
|
my $value;
|
|
if ( defined $kohafield && $kohafield ne '' ) {
|
|
next if $kohafield !~ m{^items\.}; # That would be weird!
|
|
( my $attribute = $kohafield ) =~ s|^items\.||;
|
|
$value = $self->$attribute # This call may fail if a kohafield is not a DB column but we don't want to add extra work for that there
|
|
if defined $self->$attribute and $self->$attribute ne '';
|
|
} else {
|
|
$value = $more_subfields->{$tagsubfield}
|
|
}
|
|
|
|
next unless defined $value
|
|
and $value ne q{};
|
|
|
|
if ( $subfield->{repeatable} ) {
|
|
my @values = split '\|', $value;
|
|
push @subfields, ( $tagsubfield => $_ ) for @values;
|
|
}
|
|
else {
|
|
push @subfields, ( $tagsubfield => $value );
|
|
}
|
|
|
|
}
|
|
|
|
return unless @subfields;
|
|
|
|
return MARC::Field->new(
|
|
"$itemtag", ' ', ' ', @subfields
|
|
);
|
|
}
|
|
|
|
=head3 renewal_branchcode
|
|
|
|
Returns the branchcode to be recorded in statistics renewal of the item
|
|
|
|
=cut
|
|
|
|
sub renewal_branchcode {
|
|
|
|
my ($self, $params ) = @_;
|
|
|
|
my $interface = C4::Context->interface;
|
|
my $branchcode;
|
|
if ( $interface eq 'opac' ){
|
|
my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
|
|
if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
|
|
$branchcode = 'OPACRenew';
|
|
}
|
|
elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
|
|
$branchcode = $self->homebranch;
|
|
}
|
|
elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
|
|
$branchcode = $self->checkout->patron->branchcode;
|
|
}
|
|
elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
|
|
$branchcode = $self->checkout->branchcode;
|
|
}
|
|
else {
|
|
$branchcode = "";
|
|
}
|
|
} else {
|
|
$branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
|
|
? C4::Context->userenv->{branch} : $params->{branch};
|
|
}
|
|
return $branchcode;
|
|
}
|
|
|
|
=head3 cover_images
|
|
|
|
Return the cover images associated with this item.
|
|
|
|
=cut
|
|
|
|
sub cover_images {
|
|
my ( $self ) = @_;
|
|
|
|
my $cover_image_rs = $self->_result->cover_images;
|
|
return unless $cover_image_rs;
|
|
return Koha::CoverImages->_new_from_dbic($cover_image_rs);
|
|
}
|
|
|
|
=head3 columns_to_str
|
|
|
|
my $values = $items->columns_to_str;
|
|
|
|
Return a hashref with the string representation of the different attribute of the item.
|
|
|
|
This is meant to be used for display purpose only.
|
|
|
|
=cut
|
|
|
|
sub columns_to_str {
|
|
my ( $self ) = @_;
|
|
my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
|
|
my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
|
|
my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
|
|
|
|
my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
|
|
|
|
my $values = {};
|
|
for my $column ( @{$self->_columns}) {
|
|
|
|
next if $column eq 'more_subfields_xml';
|
|
|
|
my $value = $self->$column;
|
|
# Maybe we need to deal with datetime columns here, but so far we have damaged_on, itemlost_on and withdrawn_on, and they are not linked with kohafield
|
|
|
|
if ( not defined $value or $value eq "" ) {
|
|
$values->{$column} = $value;
|
|
next;
|
|
}
|
|
|
|
my $subfield =
|
|
exists $mss->{"items.$column"}
|
|
? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
|
|
: undef;
|
|
|
|
$values->{$column} =
|
|
$subfield
|
|
? $subfield->{authorised_value}
|
|
? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
|
|
$subfield->{tagsubfield}, $value, '', $tagslib )
|
|
: $value
|
|
: $value;
|
|
}
|
|
|
|
my $marc_more=
|
|
$self->more_subfields_xml
|
|
? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
|
|
: undef;
|
|
|
|
my $more_values;
|
|
if ( $marc_more ) {
|
|
my ( $field ) = $marc_more->fields;
|
|
for my $sf ( $field->subfields ) {
|
|
my $subfield_code = $sf->[0];
|
|
my $value = $sf->[1];
|
|
my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
|
|
next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
|
|
$value =
|
|
$subfield->{authorised_value}
|
|
? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
|
|
$subfield->{tagsubfield}, $value, '', $tagslib )
|
|
: $value;
|
|
|
|
push @{$more_values->{$subfield_code}}, $value;
|
|
}
|
|
|
|
while ( my ( $k, $v ) = each %$more_values ) {
|
|
$values->{$k} = join ' | ', @$v;
|
|
}
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
=head3 additional_attributes
|
|
|
|
my $attributes = $item->additional_attributes;
|
|
$attributes->{k} = 'new k';
|
|
$item->update({ more_subfields => $attributes->to_marcxml });
|
|
|
|
Returns a Koha::Item::Attributes object that represents the non-mapped
|
|
attributes for this item.
|
|
|
|
=cut
|
|
|
|
sub additional_attributes {
|
|
my ($self) = @_;
|
|
|
|
return Koha::Item::Attributes->new_from_marcxml(
|
|
$self->more_subfields_xml,
|
|
);
|
|
}
|
|
|
|
=head3 _set_found_trigger
|
|
|
|
$self->_set_found_trigger
|
|
|
|
Finds the most recent lost item charge for this item and refunds the patron
|
|
appropriately, taking into account any payments or writeoffs already applied
|
|
against the charge.
|
|
|
|
Internal function, not exported, called only by Koha::Item->store.
|
|
|
|
=cut
|
|
|
|
sub _set_found_trigger {
|
|
my ( $self, $pre_mod_item ) = @_;
|
|
|
|
# Reverse any lost item charges if necessary.
|
|
my $no_refund_after_days =
|
|
C4::Context->preference('NoRefundOnLostReturnedItemsAge');
|
|
if ($no_refund_after_days) {
|
|
my $today = dt_from_string();
|
|
my $lost_age_in_days =
|
|
dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
|
|
->in_units('days');
|
|
|
|
return $self unless $lost_age_in_days < $no_refund_after_days;
|
|
}
|
|
|
|
my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
|
|
{
|
|
item => $self,
|
|
return_branch => C4::Context->userenv
|
|
? C4::Context->userenv->{'branch'}
|
|
: undef,
|
|
}
|
|
);
|
|
my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
|
|
|
|
if ( $lostreturn_policy ) {
|
|
|
|
# refund charge made for lost book
|
|
my $lost_charge = Koha::Account::Lines->search(
|
|
{
|
|
itemnumber => $self->itemnumber,
|
|
debit_type_code => 'LOST',
|
|
status => [ undef, { '<>' => 'FOUND' } ]
|
|
},
|
|
{
|
|
order_by => { -desc => [ 'date', 'accountlines_id' ] },
|
|
rows => 1
|
|
}
|
|
)->single;
|
|
|
|
if ( $lost_charge ) {
|
|
|
|
my $patron = $lost_charge->patron;
|
|
if ( $patron ) {
|
|
|
|
my $account = $patron->account;
|
|
|
|
# Credit outstanding amount
|
|
my $credit_total = $lost_charge->amountoutstanding;
|
|
|
|
# Use cases
|
|
if (
|
|
$lost_charge->amount > $lost_charge->amountoutstanding &&
|
|
$lostreturn_policy ne "refund_unpaid"
|
|
) {
|
|
# some amount has been cancelled. collect the offsets that are not writeoffs
|
|
# this works because the only way to subtract from this kind of a debt is
|
|
# using the UI buttons 'Pay' and 'Write off'
|
|
|
|
# We don't credit any payments if return policy is
|
|
# "refund_unpaid"
|
|
#
|
|
# In that case only unpaid/outstanding amount
|
|
# will be credited which settles the debt without
|
|
# creating extra credits
|
|
|
|
my $credit_offsets = $lost_charge->debit_offsets(
|
|
{
|
|
'credit_id' => { '!=' => undef },
|
|
'credit.credit_type_code' => { '!=' => 'Writeoff' }
|
|
},
|
|
{ join => 'credit' }
|
|
);
|
|
|
|
my $total_to_refund = ( $credit_offsets->count > 0 ) ?
|
|
# credits are negative on the DB
|
|
$credit_offsets->total * -1 :
|
|
0;
|
|
# Credit the outstanding amount, then add what has been
|
|
# paid to create a net credit for this amount
|
|
$credit_total += $total_to_refund;
|
|
}
|
|
|
|
my $credit;
|
|
if ( $credit_total > 0 ) {
|
|
my $branchcode =
|
|
C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
|
|
$credit = $account->add_credit(
|
|
{
|
|
amount => $credit_total,
|
|
description => 'Item found ' . $self->itemnumber,
|
|
type => 'LOST_FOUND',
|
|
interface => C4::Context->interface,
|
|
library_id => $branchcode,
|
|
item_id => $self->itemnumber,
|
|
issue_id => $lost_charge->issue_id
|
|
}
|
|
);
|
|
|
|
$credit->apply( { debits => [$lost_charge] } );
|
|
$self->add_message(
|
|
{
|
|
type => 'info',
|
|
message => 'lost_refunded',
|
|
payload => { credit_id => $credit->id }
|
|
}
|
|
);
|
|
}
|
|
|
|
# Update the account status
|
|
$lost_charge->status('FOUND');
|
|
$lost_charge->store();
|
|
|
|
# Reconcile balances if required
|
|
if ( C4::Context->preference('AccountAutoReconcile') ) {
|
|
$account->reconcile_balance;
|
|
}
|
|
}
|
|
}
|
|
|
|
# possibly restore fine for lost book
|
|
my $lost_overdue = Koha::Account::Lines->search(
|
|
{
|
|
itemnumber => $self->itemnumber,
|
|
debit_type_code => 'OVERDUE',
|
|
status => 'LOST'
|
|
},
|
|
{
|
|
order_by => { '-desc' => 'date' },
|
|
rows => 1
|
|
}
|
|
)->single;
|
|
if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
|
|
|
|
my $patron = $lost_overdue->patron;
|
|
if ($patron) {
|
|
my $account = $patron->account;
|
|
|
|
# Update status of fine
|
|
$lost_overdue->status('FOUND')->store();
|
|
|
|
# Find related forgive credit
|
|
my $refund = $lost_overdue->credits(
|
|
{
|
|
credit_type_code => 'FORGIVEN',
|
|
itemnumber => $self->itemnumber,
|
|
status => [ { '!=' => 'VOID' }, undef ]
|
|
},
|
|
{ order_by => { '-desc' => 'date' }, rows => 1 }
|
|
)->single;
|
|
|
|
if ( $refund ) {
|
|
# Revert the forgive credit
|
|
$refund->void({ interface => 'trigger' });
|
|
$self->add_message(
|
|
{
|
|
type => 'info',
|
|
message => 'lost_restored',
|
|
payload => { refund_id => $refund->id }
|
|
}
|
|
);
|
|
}
|
|
|
|
# Reconcile balances if required
|
|
if ( C4::Context->preference('AccountAutoReconcile') ) {
|
|
$account->reconcile_balance;
|
|
}
|
|
}
|
|
|
|
} elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
|
|
$self->add_message(
|
|
{
|
|
type => 'info',
|
|
message => 'lost_charge',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
|
|
|
|
if ( $processingreturn_policy ) {
|
|
|
|
# refund processing charge made for lost book
|
|
my $processing_charge = Koha::Account::Lines->search(
|
|
{
|
|
itemnumber => $self->itemnumber,
|
|
debit_type_code => 'PROCESSING',
|
|
status => [ undef, { '<>' => 'FOUND' } ]
|
|
},
|
|
{
|
|
order_by => { -desc => [ 'date', 'accountlines_id' ] },
|
|
rows => 1
|
|
}
|
|
)->single;
|
|
|
|
if ( $processing_charge ) {
|
|
|
|
my $patron = $processing_charge->patron;
|
|
if ( $patron ) {
|
|
|
|
my $account = $patron->account;
|
|
|
|
# Credit outstanding amount
|
|
my $credit_total = $processing_charge->amountoutstanding;
|
|
|
|
# Use cases
|
|
if (
|
|
$processing_charge->amount > $processing_charge->amountoutstanding &&
|
|
$processingreturn_policy ne "refund_unpaid"
|
|
) {
|
|
# some amount has been cancelled. collect the offsets that are not writeoffs
|
|
# this works because the only way to subtract from this kind of a debt is
|
|
# using the UI buttons 'Pay' and 'Write off'
|
|
|
|
# We don't credit any payments if return policy is
|
|
# "refund_unpaid"
|
|
#
|
|
# In that case only unpaid/outstanding amount
|
|
# will be credited which settles the debt without
|
|
# creating extra credits
|
|
|
|
my $credit_offsets = $processing_charge->debit_offsets(
|
|
{
|
|
'credit_id' => { '!=' => undef },
|
|
'credit.credit_type_code' => { '!=' => 'Writeoff' }
|
|
},
|
|
{ join => 'credit' }
|
|
);
|
|
|
|
my $total_to_refund = ( $credit_offsets->count > 0 ) ?
|
|
# credits are negative on the DB
|
|
$credit_offsets->total * -1 :
|
|
0;
|
|
# Credit the outstanding amount, then add what has been
|
|
# paid to create a net credit for this amount
|
|
$credit_total += $total_to_refund;
|
|
}
|
|
|
|
my $credit;
|
|
if ( $credit_total > 0 ) {
|
|
my $branchcode =
|
|
C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
|
|
$credit = $account->add_credit(
|
|
{
|
|
amount => $credit_total,
|
|
description => 'Item found ' . $self->itemnumber,
|
|
type => 'PROCESSING_FOUND',
|
|
interface => C4::Context->interface,
|
|
library_id => $branchcode,
|
|
item_id => $self->itemnumber,
|
|
issue_id => $processing_charge->issue_id
|
|
}
|
|
);
|
|
|
|
$credit->apply( { debits => [$processing_charge] } );
|
|
$self->add_message(
|
|
{
|
|
type => 'info',
|
|
message => 'processing_refunded',
|
|
payload => { credit_id => $credit->id }
|
|
}
|
|
);
|
|
}
|
|
|
|
# Update the account status
|
|
$processing_charge->status('FOUND');
|
|
$processing_charge->store();
|
|
|
|
# Reconcile balances if required
|
|
if ( C4::Context->preference('AccountAutoReconcile') ) {
|
|
$account->reconcile_balance;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $self;
|
|
}
|
|
|
|
=head3 public_read_list
|
|
|
|
This method returns the list of publicly readable database fields for both API and UI output purposes
|
|
|
|
=cut
|
|
|
|
sub public_read_list {
|
|
return [
|
|
'itemnumber', 'biblionumber', 'homebranch',
|
|
'holdingbranch', 'location', 'collectioncode',
|
|
'itemcallnumber', 'copynumber', 'enumchron',
|
|
'barcode', 'dateaccessioned', 'itemnotes',
|
|
'onloan', 'uri', 'itype',
|
|
'notforloan', 'damaged', 'itemlost',
|
|
'withdrawn', 'restricted'
|
|
];
|
|
}
|
|
|
|
=head3 to_api
|
|
|
|
Overloaded to_api method to ensure item-level itypes is adhered to.
|
|
|
|
=cut
|
|
|
|
sub to_api {
|
|
my ($self, $params) = @_;
|
|
|
|
my $response = $self->SUPER::to_api($params);
|
|
my $overrides = {};
|
|
|
|
$overrides->{effective_item_type_id} = $self->effective_itemtype;
|
|
|
|
my $itype_notforloan = $self->itemtype->notforloan;
|
|
$overrides->{effective_not_for_loan_status} =
|
|
( defined $itype_notforloan && !$self->notforloan ) ? $itype_notforloan : $self->notforloan;
|
|
|
|
return { %$response, %$overrides };
|
|
}
|
|
|
|
=head3 to_api_mapping
|
|
|
|
This method returns the mapping for representing a Koha::Item object
|
|
on the API.
|
|
|
|
=cut
|
|
|
|
sub to_api_mapping {
|
|
return {
|
|
itemnumber => 'item_id',
|
|
biblionumber => 'biblio_id',
|
|
biblioitemnumber => undef,
|
|
barcode => 'external_id',
|
|
dateaccessioned => 'acquisition_date',
|
|
booksellerid => 'acquisition_source',
|
|
homebranch => 'home_library_id',
|
|
price => 'purchase_price',
|
|
replacementprice => 'replacement_price',
|
|
replacementpricedate => 'replacement_price_date',
|
|
datelastborrowed => 'last_checkout_date',
|
|
datelastseen => 'last_seen_date',
|
|
stack => undef,
|
|
notforloan => 'not_for_loan_status',
|
|
damaged => 'damaged_status',
|
|
damaged_on => 'damaged_date',
|
|
itemlost => 'lost_status',
|
|
itemlost_on => 'lost_date',
|
|
withdrawn => 'withdrawn',
|
|
withdrawn_on => 'withdrawn_date',
|
|
itemcallnumber => 'callnumber',
|
|
coded_location_qualifier => 'coded_location_qualifier',
|
|
issues => 'checkouts_count',
|
|
renewals => 'renewals_count',
|
|
reserves => 'holds_count',
|
|
restricted => 'restricted_status',
|
|
itemnotes => 'public_notes',
|
|
itemnotes_nonpublic => 'internal_notes',
|
|
holdingbranch => 'holding_library_id',
|
|
timestamp => 'timestamp',
|
|
location => 'location',
|
|
permanent_location => 'permanent_location',
|
|
onloan => 'checked_out_date',
|
|
cn_source => 'call_number_source',
|
|
cn_sort => 'call_number_sort',
|
|
ccode => 'collection_code',
|
|
materials => 'materials_notes',
|
|
uri => 'uri',
|
|
itype => 'item_type_id',
|
|
more_subfields_xml => 'extended_subfields',
|
|
enumchron => 'serial_issue_number',
|
|
copynumber => 'copy_number',
|
|
stocknumber => 'inventory_number',
|
|
new_status => 'new_status',
|
|
deleted_on => undef,
|
|
};
|
|
}
|
|
|
|
=head3 itemtype
|
|
|
|
my $itemtype = $item->itemtype;
|
|
|
|
Returns Koha object for effective itemtype
|
|
|
|
=cut
|
|
|
|
sub itemtype {
|
|
my ( $self ) = @_;
|
|
|
|
return Koha::ItemTypes->find( $self->effective_itemtype );
|
|
}
|
|
|
|
=head3 orders
|
|
|
|
my $orders = $item->orders();
|
|
|
|
Returns a Koha::Acquisition::Orders object
|
|
|
|
=cut
|
|
|
|
sub orders {
|
|
my ( $self ) = @_;
|
|
|
|
my $orders = $self->_result->item_orders;
|
|
return Koha::Acquisition::Orders->_new_from_dbic($orders);
|
|
}
|
|
|
|
=head3 tracked_links
|
|
|
|
my $tracked_links = $item->tracked_links();
|
|
|
|
Returns a Koha::TrackedLinks object
|
|
|
|
=cut
|
|
|
|
sub tracked_links {
|
|
my ( $self ) = @_;
|
|
|
|
my $tracked_links = $self->_result->linktrackers;
|
|
return Koha::TrackedLinks->_new_from_dbic($tracked_links);
|
|
}
|
|
|
|
=head3 move_to_biblio
|
|
|
|
$item->move_to_biblio($to_biblio[, $params]);
|
|
|
|
Move the item to another biblio and update any references in other tables.
|
|
|
|
The final optional parameter, C<$params>, is expected to contain the
|
|
'skip_record_index' key, which is relayed down to Koha::Item->store.
|
|
There it prevents calling index_records, which takes most of the
|
|
time in batch adds/deletes. The caller must take care of calling
|
|
index_records separately.
|
|
|
|
$params:
|
|
skip_record_index => 1|0
|
|
|
|
Returns undef if the move failed or the biblionumber of the destination record otherwise
|
|
|
|
=cut
|
|
|
|
sub move_to_biblio {
|
|
my ( $self, $to_biblio, $params ) = @_;
|
|
|
|
$params //= {};
|
|
|
|
return if $self->biblionumber == $to_biblio->biblionumber;
|
|
|
|
my $from_biblionumber = $self->biblionumber;
|
|
my $to_biblionumber = $to_biblio->biblionumber;
|
|
|
|
# Own biblionumber and biblioitemnumber
|
|
$self->set({
|
|
biblionumber => $to_biblionumber,
|
|
biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
|
|
})->store({ skip_record_index => $params->{skip_record_index} });
|
|
|
|
unless ($params->{skip_record_index}) {
|
|
my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
|
|
$indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
|
|
}
|
|
|
|
# Acquisition orders
|
|
$self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
|
|
|
|
# Holds
|
|
$self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
|
|
|
|
# hold_fill_target (there's no Koha object available yet)
|
|
my $hold_fill_target = $self->_result->hold_fill_target;
|
|
if ($hold_fill_target) {
|
|
$hold_fill_target->update({ biblionumber => $to_biblionumber });
|
|
}
|
|
|
|
# tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
|
|
# and can't even fake one since the significant columns are nullable.
|
|
my $storage = $self->_result->result_source->storage;
|
|
$storage->dbh_do(
|
|
sub {
|
|
my ($storage, $dbh, @cols) = @_;
|
|
|
|
$dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
|
|
}
|
|
);
|
|
|
|
# tracked_links
|
|
$self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
|
|
|
|
return $to_biblionumber;
|
|
}
|
|
|
|
=head3 bundle_items
|
|
|
|
my $bundle_items = $item->bundle_items;
|
|
|
|
Returns the items associated with this bundle
|
|
|
|
=cut
|
|
|
|
sub bundle_items {
|
|
my ($self) = @_;
|
|
|
|
my $rs = $self->_result->bundle_items;
|
|
return Koha::Items->_new_from_dbic($rs);
|
|
}
|
|
|
|
=head3 is_bundle
|
|
|
|
my $is_bundle = $item->is_bundle;
|
|
|
|
Returns whether the item is a bundle or not
|
|
|
|
=cut
|
|
|
|
sub is_bundle {
|
|
my ($self) = @_;
|
|
return $self->bundle_items->count ? 1 : 0;
|
|
}
|
|
|
|
=head3 bundle_host
|
|
|
|
my $bundle = $item->bundle_host;
|
|
|
|
Returns the bundle item this item is attached to
|
|
|
|
=cut
|
|
|
|
sub bundle_host {
|
|
my ($self) = @_;
|
|
|
|
my $bundle_items_rs = $self->_result->item_bundles_item;
|
|
return unless $bundle_items_rs;
|
|
return Koha::Item->_new_from_dbic($bundle_items_rs->host);
|
|
}
|
|
|
|
=head3 in_bundle
|
|
|
|
my $in_bundle = $item->in_bundle;
|
|
|
|
Returns whether this item is currently in a bundle
|
|
|
|
=cut
|
|
|
|
sub in_bundle {
|
|
my ($self) = @_;
|
|
return $self->bundle_host ? 1 : 0;
|
|
}
|
|
|
|
=head3 add_to_bundle
|
|
|
|
my $link = $item->add_to_bundle($bundle_item);
|
|
|
|
Adds the bundle_item passed to this item
|
|
|
|
=cut
|
|
|
|
sub add_to_bundle {
|
|
my ( $self, $bundle_item, $options ) = @_;
|
|
|
|
$options //= {};
|
|
|
|
Koha::Exceptions::Item::Bundle::IsBundle->throw()
|
|
if ( $self->itemnumber eq $bundle_item->itemnumber
|
|
|| $bundle_item->is_bundle
|
|
|| $self->in_bundle );
|
|
|
|
my $schema = Koha::Database->new->schema;
|
|
|
|
my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
|
|
|
|
try {
|
|
$schema->txn_do(
|
|
sub {
|
|
|
|
Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
|
|
|
|
my $checkout = $bundle_item->checkout;
|
|
if ($checkout) {
|
|
unless ($options->{force_checkin}) {
|
|
Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
|
|
}
|
|
|
|
my $branchcode = C4::Context->userenv->{'branch'};
|
|
my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
|
|
unless ($success) {
|
|
Koha::Exceptions::Checkin::FailedCheckin->throw();
|
|
}
|
|
}
|
|
|
|
my $holds = $bundle_item->current_holds;
|
|
if ($holds->count) {
|
|
unless ($options->{ignore_holds}) {
|
|
Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
|
|
}
|
|
}
|
|
|
|
$self->_result->add_to_item_bundles_hosts(
|
|
{ item => $bundle_item->itemnumber } );
|
|
|
|
$bundle_item->notforloan($BundleNotLoanValue)->store();
|
|
}
|
|
);
|
|
}
|
|
catch {
|
|
|
|
# FIXME: See if we can move the below copy/paste from Koha::Object::store into it's own class and catch at a lower level in the Schema instantiation, take inspiration from DBIx::Error
|
|
if ( ref($_) eq 'DBIx::Class::Exception' ) {
|
|
if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
|
|
# FK constraints
|
|
# FIXME: MySQL error, if we support more DB engines we should implement this for each
|
|
if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
|
|
Koha::Exceptions::Object::FKConstraint->throw(
|
|
error => 'Broken FK constraint',
|
|
broken_fk => $+{column}
|
|
);
|
|
}
|
|
}
|
|
elsif (
|
|
$_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
|
|
{
|
|
Koha::Exceptions::Object::DuplicateID->throw(
|
|
error => 'Duplicate ID',
|
|
duplicate_id => $+{key}
|
|
);
|
|
}
|
|
elsif ( $_->{msg} =~
|
|
/Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
|
|
)
|
|
{ # The optional \W in the regex might be a quote or backtick
|
|
my $type = $+{type};
|
|
my $value = $+{value};
|
|
my $property = $+{property};
|
|
$property =~ s/['`]//g;
|
|
Koha::Exceptions::Object::BadValue->throw(
|
|
type => $type,
|
|
value => $value,
|
|
property => $property =~ /(\w+\.\w+)$/
|
|
? $1
|
|
: $property
|
|
, # results in table.column without quotes or backtics
|
|
);
|
|
}
|
|
|
|
# Catch-all for foreign key breakages. It will help find other use cases
|
|
$_->rethrow();
|
|
}
|
|
else {
|
|
$_->rethrow();
|
|
}
|
|
};
|
|
}
|
|
|
|
=head3 remove_from_bundle
|
|
|
|
Remove this item from any bundle it may have been attached to.
|
|
|
|
=cut
|
|
|
|
sub remove_from_bundle {
|
|
my ($self) = @_;
|
|
|
|
my $bundle_host = $self->bundle_host;
|
|
|
|
return 0 unless $bundle_host; # Should not we raise an exception here?
|
|
|
|
Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $bundle_host->checkout;
|
|
|
|
my $bundle_item_rs = $self->_result->item_bundles_item;
|
|
if ( $bundle_item_rs ) {
|
|
$bundle_item_rs->delete;
|
|
$self->notforloan(0)->store();
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
=head2 Internal methods
|
|
|
|
=head3 _after_item_action_hooks
|
|
|
|
Helper method that takes care of calling all plugin hooks
|
|
|
|
=cut
|
|
|
|
sub _after_item_action_hooks {
|
|
my ( $self, $params ) = @_;
|
|
|
|
my $action = $params->{action};
|
|
|
|
Koha::Plugins->call(
|
|
'after_item_action',
|
|
{
|
|
action => $action,
|
|
item => $self,
|
|
item_id => $self->itemnumber,
|
|
}
|
|
);
|
|
}
|
|
|
|
=head3 recall
|
|
|
|
my $recall = $item->recall;
|
|
|
|
Return the relevant recall for this item
|
|
|
|
=cut
|
|
|
|
sub recall {
|
|
my ($self) = @_;
|
|
my @recalls = Koha::Recalls->search(
|
|
{
|
|
biblio_id => $self->biblionumber,
|
|
completed => 0,
|
|
},
|
|
{ order_by => { -asc => 'created_date' } }
|
|
)->as_list;
|
|
|
|
my $item_level_recall;
|
|
foreach my $recall (@recalls) {
|
|
if ( $recall->item_level ) {
|
|
$item_level_recall = 1;
|
|
if ( $recall->item_id == $self->itemnumber ) {
|
|
return $recall;
|
|
}
|
|
}
|
|
}
|
|
if ($item_level_recall) {
|
|
|
|
# recall needs to be filled be a specific item only
|
|
# no other item is relevant to return
|
|
return;
|
|
}
|
|
|
|
# no item-level recall to return, so return earliest biblio-level
|
|
# FIXME: eventually this will be based on priority
|
|
return $recalls[0];
|
|
}
|
|
|
|
=head3 can_be_recalled
|
|
|
|
if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
|
|
|
|
Does item-level checks and returns if items can be recalled by this borrower
|
|
|
|
=cut
|
|
|
|
sub can_be_recalled {
|
|
my ( $self, $params ) = @_;
|
|
|
|
return 0 if !( C4::Context->preference('UseRecalls') );
|
|
|
|
# check if this item is not for loan, withdrawn or lost
|
|
return 0 if ( $self->notforloan != 0 );
|
|
return 0 if ( $self->itemlost != 0 );
|
|
return 0 if ( $self->withdrawn != 0 );
|
|
|
|
# check if this item is not checked out - if not checked out, can't be recalled
|
|
return 0 if ( !defined( $self->checkout ) );
|
|
|
|
my $patron = $params->{patron};
|
|
|
|
my $branchcode = C4::Context->userenv->{'branch'};
|
|
if ( $patron ) {
|
|
$branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
|
|
}
|
|
|
|
# Check the circulation rule for each relevant itemtype for this item
|
|
my $rule = Koha::CirculationRules->get_effective_rules({
|
|
branchcode => $branchcode,
|
|
categorycode => $patron ? $patron->categorycode : undef,
|
|
itemtype => $self->effective_itemtype,
|
|
rules => [
|
|
'recalls_allowed',
|
|
'recalls_per_record',
|
|
'on_shelf_recalls',
|
|
],
|
|
});
|
|
|
|
# check recalls allowed has been set and is not zero
|
|
return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
|
|
|
|
if ( $patron ) {
|
|
# check borrower has not reached open recalls allowed limit
|
|
return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
|
|
|
|
# check borrower has not reach open recalls allowed per record limit
|
|
return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
|
|
|
|
# check if this patron has already recalled this item
|
|
return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
|
|
|
|
# check if this patron has already checked out this item
|
|
return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
|
|
|
|
# check if this patron has already reserved this item
|
|
return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
|
|
}
|
|
|
|
# check item availability
|
|
# items are unavailable for recall if they are lost, withdrawn or notforloan
|
|
my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
|
|
|
|
# if there are no available items at all, no recall can be placed
|
|
return 0 if ( scalar @items == 0 );
|
|
|
|
my $checked_out_count = 0;
|
|
foreach (@items) {
|
|
if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
|
|
}
|
|
|
|
# can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
|
|
return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
|
|
|
|
# can't recall if no items have been checked out
|
|
return 0 if ( $checked_out_count == 0 );
|
|
|
|
# can recall
|
|
return 1;
|
|
}
|
|
|
|
=head3 can_be_waiting_recall
|
|
|
|
if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
|
|
|
|
Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
|
|
At this point the item has already been recalled. We are now at the checkin and set waiting stage.
|
|
|
|
=cut
|
|
|
|
sub can_be_waiting_recall {
|
|
my ( $self ) = @_;
|
|
|
|
return 0 if !( C4::Context->preference('UseRecalls') );
|
|
|
|
# check if this item is not for loan, withdrawn or lost
|
|
return 0 if ( $self->notforloan != 0 );
|
|
return 0 if ( $self->itemlost != 0 );
|
|
return 0 if ( $self->withdrawn != 0 );
|
|
|
|
my $branchcode = $self->holdingbranch;
|
|
if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
|
|
$branchcode = C4::Context->userenv->{'branch'};
|
|
} else {
|
|
$branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
|
|
}
|
|
|
|
# Check the circulation rule for each relevant itemtype for this item
|
|
my $most_relevant_recall = $self->check_recalls;
|
|
my $rule = Koha::CirculationRules->get_effective_rules(
|
|
{
|
|
branchcode => $branchcode,
|
|
categorycode => $most_relevant_recall ? $most_relevant_recall->patron->categorycode : undef,
|
|
itemtype => $self->effective_itemtype,
|
|
rules => [ 'recalls_allowed', ],
|
|
}
|
|
);
|
|
|
|
# check recalls allowed has been set and is not zero
|
|
return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
|
|
|
|
# can recall
|
|
return 1;
|
|
}
|
|
|
|
=head3 check_recalls
|
|
|
|
my $recall = $item->check_recalls;
|
|
|
|
Get the most relevant recall for this item.
|
|
|
|
=cut
|
|
|
|
sub check_recalls {
|
|
my ( $self ) = @_;
|
|
|
|
my @recalls = Koha::Recalls->search(
|
|
{ biblio_id => $self->biblionumber,
|
|
item_id => [ $self->itemnumber, undef ]
|
|
},
|
|
{ order_by => { -asc => 'created_date' } }
|
|
)->filter_by_current->as_list;
|
|
|
|
my $recall;
|
|
# iterate through relevant recalls to find the best one.
|
|
# if we come across a waiting recall, use this one.
|
|
# if we have iterated through all recalls and not found a waiting recall, use the first recall in the array, which should be the oldest recall.
|
|
foreach my $r ( @recalls ) {
|
|
if ( $r->waiting ) {
|
|
$recall = $r;
|
|
last;
|
|
}
|
|
}
|
|
unless ( defined $recall ) {
|
|
$recall = $recalls[0];
|
|
}
|
|
|
|
return $recall;
|
|
}
|
|
|
|
=head3 is_notforloan
|
|
|
|
my $is_notforloan = $item->is_notforloan;
|
|
|
|
Determine whether or not this item is "notforloan" based on
|
|
the item's notforloan status or its item type
|
|
|
|
=cut
|
|
|
|
sub is_notforloan {
|
|
my ( $self ) = @_;
|
|
my $is_notforloan = 0;
|
|
|
|
if ( $self->notforloan ){
|
|
$is_notforloan = 1;
|
|
}
|
|
else {
|
|
my $itemtype = $self->itemtype;
|
|
if ($itemtype){
|
|
if ( $itemtype->notforloan ){
|
|
$is_notforloan = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $is_notforloan;
|
|
}
|
|
|
|
=head3 is_denied_renewal
|
|
|
|
my $is_denied_renewal = $item->is_denied_renewal;
|
|
|
|
Determine whether or not this item can be renewed based on the
|
|
rules set in the ItemsDeniedRenewal system preference.
|
|
|
|
=cut
|
|
|
|
sub is_denied_renewal {
|
|
my ( $self ) = @_;
|
|
my $denyingrules = C4::Context->yaml_preference('ItemsDeniedRenewal');
|
|
return 0 unless $denyingrules;
|
|
foreach my $field (keys %$denyingrules) {
|
|
# Silently ignore bad column names; TODO we should validate elsewhere
|
|
next if !$self->_result->result_source->has_column($field);
|
|
my $val = $self->$field;
|
|
if( !defined $val) {
|
|
if ( any { !defined $_ } @{$denyingrules->{$field}} ){
|
|
return 1;
|
|
}
|
|
} elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
|
|
# If the results matches the values in the syspref
|
|
# We return true if match found
|
|
return 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
=head3 strings_map
|
|
|
|
Returns a map of column name to string representations including the string,
|
|
the mapping type and the mapping category where appropriate.
|
|
|
|
Currently handles authorised value mappings, library, callnumber and itemtype
|
|
expansions.
|
|
|
|
Accepts a param hashref where the 'public' key denotes whether we want the public
|
|
or staff client strings.
|
|
|
|
=cut
|
|
|
|
sub strings_map {
|
|
my ( $self, $params ) = @_;
|
|
my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
|
|
my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
|
|
my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
|
|
|
|
my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
|
|
|
|
# Hardcoded known 'authorised_value' values mapped to API codes
|
|
my $code_to_type = {
|
|
branches => 'library',
|
|
cn_source => 'call_number_source',
|
|
itemtypes => 'item_type',
|
|
};
|
|
|
|
# Handle not null and default values for integers and dates
|
|
my $strings = {};
|
|
|
|
foreach my $col ( @{$self->_columns} ) {
|
|
|
|
# By now, we are done with known columns, now check the framework for mappings
|
|
my $field = $self->_result->result_source->name . '.' . $col;
|
|
|
|
# Check there's an entry in the MARC subfield structure for the field
|
|
if ( exists $mss->{$field}
|
|
&& scalar @{ $mss->{$field} } > 0
|
|
&& $mss->{$field}[0]->{authorised_value} )
|
|
{
|
|
my $subfield = $mss->{$field}[0];
|
|
my $code = $subfield->{authorised_value};
|
|
|
|
my $str = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
|
|
my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
|
|
$strings->{$col} = {
|
|
str => $str,
|
|
type => $type,
|
|
( $type eq 'av' ? ( category => $code ) : () ),
|
|
};
|
|
}
|
|
}
|
|
|
|
return $strings;
|
|
}
|
|
|
|
|
|
=head3 location_update_trigger
|
|
|
|
$item->location_update_trigger( $action );
|
|
|
|
Updates the item location based on I<$action>. It is done like this:
|
|
|
|
=over 4
|
|
|
|
=item For B<checkin>, location is updated following the I<UpdateItemLocationOnCheckin> preference.
|
|
|
|
=item For B<checkout>, location is updated following the I<UpdateItemLocationOnCheckout> preference.
|
|
|
|
=back
|
|
|
|
FIXME: It should return I<$self>. See bug 35270.
|
|
|
|
=cut
|
|
|
|
sub location_update_trigger {
|
|
my ( $self, $action ) = @_;
|
|
|
|
my ( $update_loc_rules, $messages );
|
|
if ( $action eq 'checkin' ) {
|
|
$update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckin');
|
|
} else {
|
|
$update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckout');
|
|
}
|
|
|
|
if ($update_loc_rules) {
|
|
if ( defined $update_loc_rules->{_ALL_} ) {
|
|
if ( $update_loc_rules->{_ALL_} eq '_PERM_' ) {
|
|
$update_loc_rules->{_ALL_} = $self->permanent_location;
|
|
}
|
|
if ( $update_loc_rules->{_ALL_} eq '_BLANK_' ) {
|
|
$update_loc_rules->{_ALL_} = '';
|
|
}
|
|
if (
|
|
( defined $self->location && $self->location ne $update_loc_rules->{_ALL_} )
|
|
|| ( !defined $self->location
|
|
&& $update_loc_rules->{_ALL_} ne "" )
|
|
)
|
|
{
|
|
$messages->{'ItemLocationUpdated'} =
|
|
{ from => $self->location, to => $update_loc_rules->{_ALL_} };
|
|
$self->location( $update_loc_rules->{_ALL_} )->store(
|
|
{
|
|
log_action => 0,
|
|
skip_record_index => 1,
|
|
skip_holds_queue => 1
|
|
}
|
|
);
|
|
}
|
|
} else {
|
|
foreach my $key ( keys %$update_loc_rules ) {
|
|
if ( $update_loc_rules->{$key} eq '_PERM_' ) {
|
|
$update_loc_rules->{$key} = $self->permanent_location;
|
|
} elsif ( $update_loc_rules->{$key} eq '_BLANK_' ) {
|
|
$update_loc_rules->{$key} = '';
|
|
}
|
|
if (
|
|
(
|
|
defined $self->location
|
|
&& $self->location eq $key
|
|
&& $self->location ne $update_loc_rules->{$key}
|
|
)
|
|
|| ( $key eq '_BLANK_'
|
|
&& ( !defined $self->location || $self->location eq '' )
|
|
&& $update_loc_rules->{$key} ne '' )
|
|
)
|
|
{
|
|
$messages->{'ItemLocationUpdated'} = {
|
|
from => $self->location,
|
|
to => $update_loc_rules->{$key}
|
|
};
|
|
$self->location( $update_loc_rules->{$key} )->store(
|
|
{
|
|
log_action => 0,
|
|
skip_record_index => 1,
|
|
skip_holds_queue => 1
|
|
}
|
|
);
|
|
last;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $messages;
|
|
}
|
|
|
|
=head3 _type
|
|
|
|
=cut
|
|
|
|
sub _type {
|
|
return 'Item';
|
|
}
|
|
|
|
=head1 AUTHOR
|
|
|
|
Kyle M Hall <kyle@bywatersolutions.com>
|
|
|
|
=cut
|
|
|
|
1;
|