Koha-clms/Koha/Items.pm

866 lines
26 KiB
Perl

package Koha::Items;
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Array::Utils qw( array_minus );
use List::MoreUtils qw( uniq );
use Try::Tiny;
use C4::Context;
use C4::Biblio qw( GetMarcStructure GetMarcFromKohaField );
use C4::Circulation;
use Koha::Database;
use Koha::SearchEngine::Indexer;
use Koha::Item::Attributes;
use Koha::Item;
use Koha::CirculationRules;
use Koha::Regex::Replacement;
use base qw(Koha::Objects);
use Koha::SearchEngine::Indexer;
=head1 NAME
Koha::Items - Koha Item object set class
=head1 API
=head2 Class methods
=cut
=head3 filter_by_for_hold
my $filtered_items = $items->filter_by_for_hold;
Return the items of the set that are *potentially* holdable. This routine
checks only rules defined in the 'Standard rules for all libraries' and
should only be used in the context where we don't have a patron to check
policies for specifically.
Caller has the responsibility to call C4::Reserves::CanItemBeReserved before
placing a hold on one of those items.
=cut
sub filter_by_for_hold {
my ($self) = @_;
my $default_rule = Koha::CirculationRules->get_effective_rule(
{
rule_name => 'holdallowed',
}
);
my @hold_not_allowed_itypes;
if ( defined $default_rule && $default_rule->rule_value eq 'not_allowed' ) {
# If the default rule is not allowed we get all itemtypes as not allowed
@hold_not_allowed_itypes = Koha::ItemTypes->search->get_column('itemtype');
my @hold_allowed_itypes = Koha::CirculationRules->search(
{
rule_name => 'holdallowed',
rule_value => { '!=' => 'not_allowed' },
branchcode => undef,
categorycode => undef,
}
)->get_column('itemtype');
# We then only allow those explicitly defined in hold policies at the all libraries level
@hold_not_allowed_itypes = array_minus( @hold_not_allowed_itypes, @hold_allowed_itypes );
} else {
# If there is no default 'not_allowed' rule, then only those explicitly forbidden at the all libraries level are forbidden
@hold_not_allowed_itypes = Koha::CirculationRules->search(
{
rule_name => 'holdallowed',
branchcode => undef,
categorycode => undef,
rule_value => 'not_allowed',
}
)->get_column('itemtype');
}
# We also forbid holds on any marked not for loan at the item level
push @hold_not_allowed_itypes, Koha::ItemTypes->search( { notforloan => 1 } )->get_column('itemtype');
@hold_not_allowed_itypes = uniq @hold_not_allowed_itypes;
my $params = {
itemlost => 0,
withdrawn => 0,
notforloan => { '<=' => 0 }, # items with negative or zero notforloan value are holdable
( C4::Context->preference('AllowHoldsOnDamagedItems') ? () : ( damaged => 0 ) ),
( C4::Context->only_my_library() ? ( homebranch => C4::Context::mybranch() ) : () ),
};
if ( C4::Context->preference("item-level_itypes") ) {
return $self->search(
{
%$params,
itype => { -not_in => \@hold_not_allowed_itypes },
}
);
} else {
return $self->search(
{
%$params,
'biblioitem.itemtype' => { -not_in => \@hold_not_allowed_itypes },
},
{
join => 'biblioitem',
}
);
}
}
=head3 filter_by_visible_in_opac
my $filered_items = $items->filter_by_visible_in_opac(
{
[ patron => $patron ]
}
);
Returns a new resultset, containing those items that are not expected to be hidden in OPAC
for the passed I<Koha::Patron> object that is passed.
The I<OpacHiddenItems>, I<hidelostitems> and I<OpacHiddenItemsExceptions> system preferences
are honoured.
=cut
sub filter_by_visible_in_opac {
my ( $self, $params ) = @_;
my $patron = $params->{patron};
my $result = $self;
# Filter out OpacHiddenItems unless disabled by OpacHiddenItemsExceptions
unless ( $patron and $patron->category->override_hidden_items ) {
my $rules = C4::Context->yaml_preference('OpacHiddenItems') // {};
my $rules_params;
foreach my $field ( keys %$rules ) {
$rules_params->{ 'me.' . $field } =
[ { '-not_in' => $rules->{$field} }, undef ];
}
$result = $result->search($rules_params);
}
if ( C4::Context->preference('hidelostitems') ) {
$result = $result->filter_out_lost;
}
return $result;
}
=head3 filter_out_lost
my $filered_items = $items->filter_out_lost;
Returns a new resultset, containing those items that are not marked as lost.
=cut
sub filter_out_lost {
my ($self) = @_;
my $params = { itemlost => 0 };
return $self->search($params);
}
=head3 filter_by_bookable
my $filterd_items = $items->filter_by_bookable;
Returns a new resultset, containing only those items that are allowed to be booked.
=cut
sub filter_by_bookable {
my ($self) = @_;
if ( !C4::Context->preference('item-level_itypes') ) {
return $self->search(
[
{ bookable => 1 },
{
bookable => undef,
'biblioitem.itemtype' =>
{ -in => [ Koha::ItemTypes->search( { bookable => 1 } )->get_column('itemtype') ] }
},
],
{ join => 'biblioitem' }
);
}
return $self->search(
[
{ bookable => 1 },
{
bookable => undef,
itype => { -in => [ Koha::ItemTypes->search( { bookable => 1 } )->get_column('itemtype') ] }
},
]
);
}
=head3 filter_by_checked_out
my $checked_out_items = $items->filter_by_checked_out;
Returns a new resultset, containing only those items that are currently checked out.
=cut
sub filter_by_checked_out {
my ( $self, $params ) = @_;
$params //= {};
my $checkouts = Koha::Checkouts->search(
{ %$params, 'me.itemnumber' => [ $self->get_column('itemnumber') ], },
{
columns => ['itemnumber'],
distinct => 1
}
)->_resultset->as_query;
return $self->search( { 'me.itemnumber' => { '-in' => $checkouts } } );
}
=head3 filter_by_in_transit
my $in_tranist_items = $items->filter_by_in_transit;
Returns a new resultset, containing only those items that are currently in transit.
=cut
sub filter_by_in_transit {
my ( $self, $params ) = @_;
$params //= {};
$params->{datesent} = { '!=' => undef };
$params->{datearrived} = undef;
$params->{datecancelled} = undef;
my $transfers = Koha::Item::Transfers->search(
{ %$params, 'me.itemnumber' => [ $self->get_column('itemnumber') ], },
{
columns => ['itemnumber'],
distinct => 1
}
)->_resultset->as_query;
return $self->search( { 'me.itemnumber' => { '-in' => $transfers } } );
}
=head3 filter_by_has_holds
my $has_hold_items = $items->filter_by_has_holds;
Returns a new resultset, containing only those items that currently have holds.
=cut
sub filter_by_has_holds {
my ( $self, $params ) = @_;
$params //= {};
my $holds = Koha::Holds->search(
{ %$params, 'me.itemnumber' => [ $self->get_column('itemnumber') ], },
{
columns => ['itemnumber'],
distinct => 1
}
)->_resultset->as_query;
return $self->search( { 'me.itemnumber' => { '-in' => $holds } } );
}
=head3 filter_by_has_recalls
my $has_recalls_items = $items->filter_by_has_recalls;
Returns a new resultset, containing only those items that currently have recalls.
=cut
sub filter_by_has_recalls {
my ( $self, $params ) = @_;
$params //= {};
my $recalls = Koha::Recalls->search(
{ %$params, 'me.item_id' => [ $self->get_column('itemnumber') ], 'me.item_level' => 1, },
{
columns => ['item_id'],
distinct => 1
}
)->_resultset->as_query;
return $self->search( { 'me.itemnumber' => { '-in' => $recalls } } );
}
=head3 filter_by_in_bundle
Returns a new resultset, containing only those items that currently are part of a bundle.
=cut
sub filter_by_in_bundle {
my ($self) = @_;
my @in_bundle_items;
while ( my $item = $self->next ) {
push @in_bundle_items, $item if $item->in_bundle;
}
my @bundled_items = map { $_->itemnumber } @in_bundle_items;
return $self->search( { 'me.itemnumber' => { '-in' => \@bundled_items } } );
}
=head3 filter_by_available
my $available_items = $items->filter_by_available;
Returns a new resultset, containing only those items that are currently available.
=cut
sub filter_by_available {
my ($self) = @_;
my @all_itemnumbers = $self->get_column('itemnumber');
my @not_available_itemnumbers;
push @not_available_itemnumbers, $self->filter_by_checked_out->get_column('itemnumber');
push @not_available_itemnumbers, $self->filter_by_in_transit->get_column('itemnumber');
push @not_available_itemnumbers, $self->filter_by_has_holds->get_column('itemnumber');
push @not_available_itemnumbers, $self->filter_by_has_recalls->get_column('itemnumber');
my @item_types_notforloan = Koha::ItemTypes->search( { notforloan => { '!=' => 0 } } )->get_column('itemtype');
return Koha::Items->search(
{
'me.itemnumber' => [ array_minus @all_itemnumbers, @not_available_itemnumbers ],
itemlost => 0,
withdrawn => 0,
damaged => 0,
notforloan => 0,
restricted => [ { '!=' => 0 }, undef ],
'me.itype' => { -not_in => \@item_types_notforloan },
}
);
}
=head3 move_to_biblio
$items->move_to_biblio($to_biblio);
Move items to a given biblio.
=cut
sub move_to_biblio {
my ( $self, $to_biblio ) = @_;
my $biblionumbers = { $to_biblio->biblionumber => 1 };
while ( my $item = $self->next() ) {
$biblionumbers->{ $item->biblionumber } = 1;
$item->move_to_biblio( $to_biblio, { skip_record_index => 1 } );
}
my $indexer = Koha::SearchEngine::Indexer->new( { index => $Koha::SearchEngine::BIBLIOS_INDEX } );
for my $biblionumber ( keys %{$biblionumbers} ) {
$indexer->index_records( $biblionumber, "specialUpdate", "biblioserver" );
}
}
=head3 batch_update
Koha::Items->search->batch_update
{
new_values => {
itemnotes => $new_item_notes,
k => $k,
},
regex_mod => {
itemnotes_nonpublic => {
search => 'foo',
replace => 'bar',
modifiers => 'gi',
},
},
exclude_from_local_holds_priority => 1|0,
callback => sub {
# increment something here
},
}
);
Batch update the items.
Returns ( $report, $self )
Report has 2 keys:
* modified_itemnumbers - list of the modified itemnumbers
* modified_fields - number of fields modified
Parameters:
=over
=item new_values
Allows to set a new value for given fields.
The key can be one of the item's column name, or one subfieldcode of a MARC subfields not linked with a Koha field
=item regex_mod
Allows to modify existing subfield's values using a regular expression
=item exclude_from_local_holds_priority
Set the passed boolean value to items.exclude_from_local_holds_priority
=item mark_items_returned
Move issues on these items to the old issues table, do not mark items found, or
adjust damaged/withdrawn statuses, or fines, or locations.
=item callback
Callback function to call after an item has been modified
=back
=cut
sub batch_update {
my ( $self, $params ) = @_;
my $regex_mod = $params->{regex_mod} || {};
my $new_values = $params->{new_values} || {};
my $exclude_from_local_holds_priority = $params->{exclude_from_local_holds_priority};
my $mark_items_returned = $params->{mark_items_returned};
my $callback = $params->{callback};
my ( @modified_itemnumbers, $modified_fields );
my $i;
my @errors;
my $schema = Koha::Database->new->schema;
while ( my $item = $self->next ) {
try {
$schema->txn_do(
sub {
my $modified_holds_priority = 0;
my $item_returned = 0;
if ( defined $exclude_from_local_holds_priority ) {
if ( !defined $item->exclude_from_local_holds_priority
|| $item->exclude_from_local_holds_priority != $exclude_from_local_holds_priority )
{
$item->exclude_from_local_holds_priority($exclude_from_local_holds_priority)->store;
$modified_holds_priority = 1;
}
}
my $modified = 0;
my $new_values = {%$new_values}; # Don't modify the original
my $old_values = $item->unblessed;
if ( $item->more_subfields_xml ) {
$old_values = {
%$old_values,
%{ $item->additional_attributes->to_hashref },
};
}
for my $attr ( keys %$regex_mod ) {
my $old_value = $old_values->{$attr};
next unless $old_value;
my $value = apply_regex(
{
%{ $regex_mod->{$attr} },
value => $old_value,
}
);
$new_values->{$attr} = $value;
}
for my $attribute ( keys %$new_values ) {
next if $attribute eq 'more_subfields_xml'; # Already counted before
my $old = $old_values->{$attribute};
my $new = $new_values->{$attribute};
$modified++
if ( defined $old xor defined $new )
|| ( defined $old && defined $new && $new ne $old );
}
{ # Dealing with more_subfields_xml
my $frameworkcode = $item->biblio->frameworkcode;
my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
my ( $itemtag, $itemsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
my @more_subfield_tags = map {
(
ref($_)
&& %$_
&& !$_->{kohafield} # Get subfields that are not mapped
)
? $_->{tagsubfield}
: ()
} values %{ $tagslib->{$itemtag} };
my $more_subfields_xml = Koha::Item::Attributes->new(
{
map {
exists $new_values->{$_} ? ( $_ => $new_values->{$_} )
: exists $old_values->{$_} ? ( $_ => $old_values->{$_} )
: ()
} @more_subfield_tags
}
)->to_marcxml($frameworkcode);
$new_values->{more_subfields_xml} = $more_subfields_xml;
delete $new_values->{$_} for @more_subfield_tags; # Clean the hash
}
if ($modified) {
my $itemlost_pre = $item->itemlost;
$item->set($new_values)->store( { skip_record_index => 1 } );
C4::Circulation::LostItem(
$item->itemnumber, 'batchmod', undef,
{ skip_record_index => 1 }
)
if $item->itemlost
and not $itemlost_pre;
}
if ($mark_items_returned) {
my $issue = $item->checkout;
if ($issue) {
$item_returned = 1;
C4::Circulation::MarkIssueReturned(
$issue->borrowernumber,
$item->itemnumber,
undef,
$issue->patron->privacy,
{
skip_record_index => 1,
skip_holds_queue => 1,
}
);
}
}
push @modified_itemnumbers, $item->itemnumber
if $modified || $modified_holds_priority || $item_returned;
$modified_fields += $modified + $modified_holds_priority + $item_returned;
}
)
} catch {
push @errors, {
biblionumber => $item->biblionumber,
itemnumber => $item->itemnumber,
barcode => $item->barcode,
error => eval { $_->message } || "$_",
};
warn $_
};
if ($callback) {
$callback->( ++$i );
}
}
if (@modified_itemnumbers) {
my @biblionumbers =
uniq(
Koha::Items->search( { itemnumber => { -in => \@modified_itemnumbers } } )->get_column('biblionumber') );
if (@biblionumbers) {
my $indexer = Koha::SearchEngine::Indexer->new( { index => $Koha::SearchEngine::BIBLIOS_INDEX } );
$indexer->index_records(
\@biblionumbers, 'specialUpdate',
"biblioserver", undef
);
}
}
return (
{ modified_itemnumbers => \@modified_itemnumbers, modified_fields => $modified_fields, errors => \@errors },
$self
);
}
=head2 apply_regex
Missing POD for apply_regex.
=cut
sub apply_regex {
# FIXME Should be moved outside of Koha::Items
# FIXME This is nearly identical to Koha::SimpleMARC::_modify_values
my ($params) = @_;
my $search = $params->{search};
my $replace = $params->{replace};
my $modifiers = $params->{modifiers} || q{};
my $value = $params->{value};
my @available_modifiers = qw( i g );
my $retained_modifiers = q||;
for my $modifier ( split //, $modifiers ) {
$retained_modifiers .= $modifier
if grep { /$modifier/ } @available_modifiers;
}
# The replacement is expanded as DATA (capture-group backreferences and the
# \n \r \t escapes) and never evaluated as code, so @{...}/${...}
# interpolation blocks are inert text, not Perl.
if ( $retained_modifiers =~ m/^(ig|gi)$/ ) {
$value =~ s/$search/Koha::Regex::Replacement::expand_template( $replace, [@{^CAPTURE}], {%+} )/ige;
} elsif ( $retained_modifiers eq 'i' ) {
$value =~ s/$search/Koha::Regex::Replacement::expand_template( $replace, [@{^CAPTURE}], {%+} )/ie;
} elsif ( $retained_modifiers eq 'g' ) {
$value =~ s/$search/Koha::Regex::Replacement::expand_template( $replace, [@{^CAPTURE}], {%+} )/ge;
} else {
$value =~ s/$search/Koha::Regex::Replacement::expand_template( $replace, [@{^CAPTURE}], {%+} )/e;
}
return $value;
}
=head3 search
my $search_result = $object->search( $params, $attributes );
Filters items based on the specified status.
=cut
sub _extract_custom_attrs {
my ($params) = @_;
my %custom_attrs;
my $clean_params = _process_params( $params, \%custom_attrs );
return ( \%custom_attrs, $clean_params );
}
sub _process_params {
my ( $params, $custom_attrs ) = @_;
return $params unless ref $params;
if ( ref $params eq 'HASH' ) {
my %clean;
for my $key ( keys %$params ) {
if ( $key =~ /^_(.+)/ ) {
$custom_attrs->{$key} = $params->{$key};
} else {
$clean{$key} = _process_params( $params->{$key}, $custom_attrs );
}
}
return \%clean;
} elsif ( ref $params eq 'ARRAY' ) {
return [ map { _process_params( $_, $custom_attrs ) } @$params ];
} else {
return $params;
}
}
=head3 _validate_params
Verify is the input provided with _status is valid.
Since we have to tweak the search for researches involving the _status parameter, if the search involves a _status, we need to check that the provided hashref is compatible with the use case.
An hashref is compatible if it is not nested or if the lonely level of nesting is under a "-and" parameter
=cut
sub _validate_params {
my ( $clean_params, $custom_attrs ) = @_;
return 1 unless exists $custom_attrs->{_status};
if ( ref($clean_params) eq 'HASH' ) {
if ( grep { $_ eq '-and' } keys %$clean_params ) {
my $and_hashref = $clean_params->{'-and'};
if ( ref($and_hashref) eq 'HASH' && !grep { ref( $and_hashref->{$_} ) eq 'HASH' } keys %$and_hashref ) {
return 1;
}
}
return 1 unless grep { ref( $clean_params->{$_} ) eq 'HASH' } keys %$clean_params;
}
return 0;
}
sub search {
my ( $self, $params, $attributes ) = @_;
my ( $custom_attrs, $clean_params ) = _extract_custom_attrs($params);
unless ( _validate_params( $clean_params, $custom_attrs ) ) {
Koha::Exceptions::BadParameter->throw(
error => "Filtering on status is only implemented for -and search",
);
}
$params = $clean_params;
my $status = $custom_attrs->{_status};
if ($status) {
if ( $status eq 'checked_out' ) {
$self = $self->filter_by_checked_out( { onsite_checkout => 0 } );
}
if ( $status eq 'local_use' ) {
$self = $self->filter_by_checked_out( { onsite_checkout => 1 } );
}
if ( $status eq 'in_transit' ) {
$self = $self->filter_by_in_transit;
}
if ( $status eq 'lost' ) {
$self = $self->search( { itemlost => { '!=' => 0 } } );
}
if ( $status =~ '^lost-(.*)' ) {
$self = $self->search( { itemlost => $1 } );
}
if ( $status eq 'withdrawn' ) {
$self = $self->search( { withdrawn => { '!=' => 0 } } );
}
if ( $status =~ '^withdrawn-(.*)' ) {
$self = $self->search( { withdrawn => $1 } );
}
if ( $status eq 'damaged' ) {
$self = $self->search( { damaged => { '!=' => 0 } } );
}
if ( $status =~ '^damaged-(.*)' ) {
$self = $self->search( { damaged => $1 } );
}
if ( $status eq 'not_for_loan' ) {
my @item_types_notforloan =
Koha::ItemTypes->search( { notforloan => { '!=' => 0 } } )->get_column('itemtype');
$self = $self->search( [ { notforloan => { '!=' => 0 } }, { 'me.itype' => \@item_types_notforloan } ] );
}
if ( $status =~ '^not_for_loan-(.*)' ) {
my $av_not_for_loan = $1;
my @item_types_notforloan =
Koha::ItemTypes->search( { notforloan => $av_not_for_loan } )->get_column('itemtype');
$self = $self->search( [ { notforloan => $av_not_for_loan }, { 'me.itype' => \@item_types_notforloan } ] );
}
if ( $status eq 'on_hold' ) {
$self = $self->filter_by_has_holds;
}
if ( $status eq 'recalled' ) {
$self = $self->filter_by_has_recalls;
}
if ( $status eq 'in_bundle' ) {
$self = $self->filter_by_in_bundle;
}
if ( $status eq 'available' ) {
$self = $self->filter_by_available;
}
if ( $status eq 'restricted' ) {
$self = $self->search( { restricted => [ { '!=' => 0 } ] } );
}
}
return $self->SUPER::search( $params, $attributes );
}
=head3 search_ordered
$items->search_ordered;
Search and sort items in a specific order, depending if serials are present or not
=cut
sub search_ordered {
my ( $self, $params, $attributes ) = @_;
$self = $self->search( $params, $attributes );
my @biblionumbers = uniq $self->search( undef, { distinct => 1 } )->get_column('biblionumber');
if ( scalar(@biblionumbers) == 1
&& Koha::Biblios->find( $biblionumbers[0] )->serial )
{
return $self->search(
{},
{
order_by => [ 'serialid.publisheddate', 'me.enumchron' ],
join => { serialitem => 'serialid' }
}
);
} else {
return $self->search(
{},
{
order_by => [
'homebranch.branchname',
'me.enumchron',
{ -desc => 'me.dateaccessioned' }
],
join => ['homebranch']
}
);
}
}
=head2 Internal methods
=head3 _type
=cut
sub _type {
return 'Item';
}
=head3 object_class
=cut
sub object_class {
return 'Koha::Item';
}
=head1 AUTHOR
Kyle M Hall <kyle@bywatersolutions.com>
Tomas Cohen Arazi <tomascohen@theke.io>
Martin Renvoize <martin.renvoize@ptfs-europe.com>
=cut
1;