3 # Copyright ByWater Solutions 2014
5 # This file is part of Koha.
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
22 use List::MoreUtils qw( any );
23 use Try::Tiny qw( catch try );
26 use Koha::DateUtils qw( dt_from_string output_pref );
29 use C4::Circulation qw( barcodedecode GetBranchItemRule );
31 use C4::ClassSource qw( GetClassSort );
32 use C4::Log qw( logaction );
34 use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue;
35 use Koha::Biblio::ItemGroups;
37 use Koha::CirculationRules;
38 use Koha::CoverImages;
40 use Koha::Exceptions::Checkin;
41 use Koha::Exceptions::Item::Bundle;
42 use Koha::Exceptions::Item::Transfer;
43 use Koha::Item::Attributes;
44 use Koha::Exceptions::Item::Bundle;
45 use Koha::Item::Transfer::Limits;
46 use Koha::Item::Transfers;
52 use Koha::Result::Boolean;
53 use Koha::SearchEngine::Indexer;
54 use Koha::Serial::Items;
55 use Koha::StockRotationItem;
56 use Koha::StockRotationRotas;
57 use Koha::TrackedLinks;
58 use Koha::Policy::Holds;
60 use base qw(Koha::Object);
64 Koha::Item - Koha Item object class
76 $params can take an optional 'skip_record_index' parameter.
77 If set, the reindexation process will not happen (index_records not called)
78 You should not turn it on if you do not understand what it is doing exactly.
84 my $params = @_ ? shift : {};
86 my $log_action = $params->{log_action} // 1;
88 # We do not want to oblige callers to pass this value
89 # Dev conveniences vs performance?
90 unless ( $self->biblioitemnumber ) {
91 $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
94 # See related changes from C4::Items::AddItem
95 unless ( $self->itype ) {
96 $self->itype($self->biblio->biblioitem->itemtype);
99 $self->barcode( C4::Circulation::barcodedecode( $self->barcode ) );
101 my $today = dt_from_string;
102 my $action = 'create';
104 unless ( $self->in_storage ) { #AddItem
106 unless ( $self->permanent_location ) {
107 $self->permanent_location($self->location);
110 my $default_location = C4::Context->preference('NewItemsDefaultLocation');
111 unless ( $self->location || !$default_location ) {
112 $self->permanent_location( $self->location || $default_location )
113 unless $self->permanent_location;
114 $self->location($default_location);
117 unless ( $self->replacementpricedate ) {
118 $self->replacementpricedate($today);
120 unless ( $self->datelastseen ) {
121 $self->datelastseen($today);
124 unless ( $self->dateaccessioned ) {
125 $self->dateaccessioned($today);
128 if ( $self->itemcallnumber
129 or $self->cn_source )
131 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
132 $self->cn_sort($cn_sort);
135 # should be quite rare when adding item
136 if ( $self->itemlost && $self->itemlost > 0 ) { # TODO BZ34308
137 $self->_add_statistic('item_lost');
144 my %updated_columns = $self->_result->get_dirty_columns;
145 return $self->SUPER::store unless %updated_columns;
147 # Retrieve the item for comparison if we need to
149 exists $updated_columns{itemlost}
150 or exists $updated_columns{withdrawn}
151 or exists $updated_columns{damaged}
152 ) ? $self->get_from_storage : undef;
154 # Update *_on fields if needed
155 # FIXME: Why not for AddItem as well?
156 my @fields = qw( itemlost withdrawn damaged );
157 for my $field (@fields) {
159 # If the field is defined but empty or 0, we are
160 # removing/unsetting and thus need to clear out
162 if ( exists $updated_columns{$field}
163 && defined( $self->$field )
166 my $field_on = "${field}_on";
167 $self->$field_on(undef);
169 # If the field has changed otherwise, we much update
171 elsif (exists $updated_columns{$field}
172 && $updated_columns{$field}
173 && !$pre_mod_item->$field )
175 my $field_on = "${field}_on";
176 $self->$field_on(dt_from_string);
180 if ( exists $updated_columns{itemcallnumber}
181 or exists $updated_columns{cn_source} )
183 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
184 $self->cn_sort($cn_sort);
188 if ( exists $updated_columns{location}
189 and ( !defined($self->location) or $self->location !~ /^(CART|PROC)$/ )
190 and not exists $updated_columns{permanent_location} )
192 $self->permanent_location( $self->location );
195 # TODO BZ 34308 (gt zero checks)
196 if ( exists $updated_columns{itemlost}
197 && ( !$updated_columns{itemlost} || $updated_columns{itemlost} <= 0 )
198 && ( $pre_mod_item->itemlost && $pre_mod_item->itemlost > 0 ) )
201 # reverse any list item charges if necessary
202 $self->_set_found_trigger($pre_mod_item);
203 $self->_add_statistic('item_found');
204 } elsif ( exists $updated_columns{itemlost}
205 && ( $updated_columns{itemlost} && $updated_columns{itemlost} > 0 )
206 && ( !$pre_mod_item->itemlost || $pre_mod_item->itemlost <= 0 ) )
209 $self->_add_statistic('item_lost');
213 my $result = $self->SUPER::store;
214 if ( $log_action && C4::Context->preference("CataloguingLog") ) {
216 ? logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
217 : logaction( "CATALOGUING", "MODIFY", $self->itemnumber, $self );
219 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
220 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
221 unless $params->{skip_record_index};
222 $self->get_from_storage->_after_item_action_hooks({ action => $action });
224 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
226 biblio_ids => [ $self->biblionumber ]
228 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
234 my ( $self, $type ) = @_;
235 C4::Stats::UpdateStats(
237 borrowernumber => undef,
238 branch => C4::Context->userenv ? C4::Context->userenv->{branch} : undef,
239 categorycode => undef,
240 ccode => $self->ccode,
241 itemnumber => $self->itemnumber,
242 itemtype => $self->effective_itemtype,
243 location => $self->location,
255 my $params = @_ ? shift : {};
257 # FIXME check the item has no current issues
258 # i.e. raise the appropriate exception
260 # Get the item group so we can delete it later if it has no items left
261 my $item_group = C4::Context->preference('EnableItemGroups') ? $self->item_group : undef;
263 my $result = $self->SUPER::delete;
265 # Delete the item group if it has no items left
266 $item_group->delete if ( $item_group && $item_group->items->count == 0 );
268 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
269 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
270 unless $params->{skip_record_index};
272 $self->_after_item_action_hooks({ action => 'delete' });
274 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
275 if C4::Context->preference("CataloguingLog");
277 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
279 biblio_ids => [ $self->biblionumber ]
281 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
292 my $params = @_ ? shift : {};
294 my $safe_to_delete = $self->safe_to_delete;
295 return $safe_to_delete unless $safe_to_delete;
297 $self->move_to_deleted;
299 return $self->delete($params);
302 =head3 safe_to_delete
304 returns 1 if the item is safe to delete,
306 "book_on_loan" if the item is checked out,
308 "not_same_branch" if the item is blocked by independent branches,
310 "book_reserved" if the there are holds aganst the item, or
312 "linked_analytics" if the item has linked analytic records.
314 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
323 $error = "book_on_loan" if $self->checkout;
325 $error //= "not_same_branch"
326 if defined C4::Context->userenv
327 and defined C4::Context->userenv->{number}
328 and !Koha::Patrons->find( C4::Context->userenv->{number} )->can_edit_items_from( $self->homebranch );
330 # check it doesn't have a waiting reserve
331 $error //= "book_reserved"
332 if $self->holds->filter_by_found->count;
334 $error //= "linked_analytics"
335 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
337 $error //= "last_item_for_hold"
338 if $self->biblio->items->count == 1
339 && $self->biblio->holds->search(
346 return Koha::Result::Boolean->new(0)->add_message({ message => $error });
349 return Koha::Result::Boolean->new(1);
352 =head3 move_to_deleted
354 my $is_moved = $item->move_to_deleted;
356 Move an item to the deleteditems table.
357 This can be done before deleting an item, to make sure the data are not completely deleted.
361 sub move_to_deleted {
363 my $item_infos = $self->unblessed;
364 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
365 $item_infos->{deleted_on} = dt_from_string;
366 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
369 =head3 effective_itemtype
371 Returns the itemtype for the item based on whether item level itemtypes are set or not.
375 sub effective_itemtype {
378 return $self->_result()->effective_itemtype();
388 my $hb_rs = $self->_result->homebranch;
390 return Koha::Library->_new_from_dbic( $hb_rs );
393 =head3 holding_branch
400 my $hb_rs = $self->_result->holdingbranch;
402 return Koha::Library->_new_from_dbic( $hb_rs );
407 my $biblio = $item->biblio;
409 Return the bibliographic record of this item
415 my $biblio_rs = $self->_result->biblio;
416 return Koha::Biblio->_new_from_dbic( $biblio_rs );
421 my $biblioitem = $item->biblioitem;
423 Return the biblioitem record of this item
429 my $biblioitem_rs = $self->_result->biblioitem;
430 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
435 my $checkout = $item->checkout;
437 Return the checkout for this item
443 my $checkout_rs = $self->_result->issue;
444 return unless $checkout_rs;
445 return Koha::Checkout->_new_from_dbic( $checkout_rs );
454 my $rs = $self->_result->serialitem;
456 return Koha::Serial::Item->_new_from_dbic($rs);
461 my $item_group = $item->item_group;
463 Return the item group for this item
470 my $item_group_item = $self->_result->item_group_item;
471 return unless $item_group_item;
473 my $item_group_rs = $item_group_item->item_group;
474 return unless $item_group_rs;
476 my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs );
480 sub item_group_item {
482 my $rs = $self->_result->item_group_item;
484 return Koha::Biblio::ItemGroup::Item->_new_from_dbic($rs);
490 my $return_claims = $item->return_claims;
492 Return any return_claims associated with this item
497 my ( $self, $params, $attrs ) = @_;
498 my $claims_rs = $self->_result->return_claims->search($params, $attrs);
499 return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs );
504 my $return_claim = $item->return_claim;
506 Returns the most recent unresolved return_claims associated with this item
513 $self->_result->return_claims->search( { resolution => undef },
514 { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single;
515 return unless $claims_rs;
516 return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs);
521 my $holds = $item->holds();
522 my $holds = $item->holds($params);
523 my $holds = $item->holds({ found => 'W'});
525 Return holds attached to an item, optionally accept a hashref of params to pass to search
530 my ( $self,$params ) = @_;
531 my $holds_rs = $self->_result->reserves->search($params);
532 return Koha::Holds->_new_from_dbic( $holds_rs );
537 my $bookings = $item->bookings();
539 Returns the bookings attached to this item.
544 my ( $self, $params ) = @_;
545 my $bookings_rs = $self->_result->bookings->search($params);
546 return Koha::Bookings->_new_from_dbic($bookings_rs);
551 my $booking = $item->find_booking( { checkout_date => $now, due_date => $future_date } );
553 Find the first booking that would conflict with the passed checkout dates for this item.
555 FIXME: This can be simplified, it was originally intended to iterate all biblio level bookings
556 to catch cases where this item may be the last available to satisfy a biblio level only booking.
557 However, we dropped the biblio level functionality prior to push as bugs were found in it's
563 my ( $self, $params ) = @_;
565 my $checkout_date = $params->{checkout_date};
566 my $due_date = $params->{due_date};
567 my $biblio = $self->biblio;
569 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
570 my $bookings = $biblio->bookings(
572 # Proposed checkout starts during booked period
575 $dtf->format_datetime($checkout_date),
576 $dtf->format_datetime($due_date)
580 # Proposed checkout is due during booked period
583 $dtf->format_datetime($checkout_date),
584 $dtf->format_datetime($due_date)
588 # Proposed checkout would contain the booked period
590 start_date => { '<' => $dtf->format_datetime($checkout_date) },
591 end_date => { '>' => $dtf->format_datetime($due_date) }
594 { order_by => { '-asc' => 'start_date' } }
598 my $loanable_items = {};
599 my $bookable_items = $biblio->bookable_items;
600 while ( my $item = $bookable_items->next ) {
601 $loanable_items->{ $item->itemnumber } = 1;
602 if ( my $checkout = $item->checkout ) {
603 $checkouts->{ $item->itemnumber } = dt_from_string( $checkout->date_due );
607 while ( my $booking = $bookings->next ) {
609 # Booking for this item
610 if ( defined( $booking->item_id )
611 && $booking->item_id == $self->itemnumber )
616 # Booking for another item
617 elsif ( defined( $booking->item_id ) ) {
618 # Due for another booking, remove from pool
619 delete $loanable_items->{ $booking->item_id };
624 # Booking for any item
626 # Can another item satisfy this booking?
635 $item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
637 Returns a boolean denoting whether the passed booking can be made without clashing.
639 Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
644 my ( $self, $params ) = @_;
646 my $start_date = dt_from_string( $params->{start_date} );
647 my $end_date = dt_from_string( $params->{end_date} );
648 my $booking_id = $params->{booking_id};
650 if ( my $checkout = $self->checkout ) {
651 return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
654 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
656 my $existing_bookings = $self->bookings(
660 $dtf->format_datetime($start_date),
661 $dtf->format_datetime($end_date)
666 $dtf->format_datetime($start_date),
667 $dtf->format_datetime($end_date)
671 start_date => { '<' => $dtf->format_datetime($start_date) },
672 end_date => { '>' => $dtf->format_datetime($end_date) }
679 ? $existing_bookings->search( { booking_id => { '!=' => $booking_id } } )->count
680 : $existing_bookings->count;
682 return $bookings_count ? 0 : 1;
685 =head3 request_transfer
687 my $transfer = $item->request_transfer(
691 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
695 Add a transfer request for this item to the given branch for the given reason.
697 An exception will be thrown if the BranchTransferLimits would prevent the requested
698 transfer, unless 'ignore_limits' is passed to override the limits.
700 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
701 The caller should catch such cases and retry the transfer request as appropriate passing
702 an appropriate override.
705 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
706 * replace - Used to replace the existing transfer request with your own.
710 sub request_transfer {
711 my ( $self, $params ) = @_;
713 # check for mandatory params
714 my @mandatory = ( 'to', 'reason' );
715 for my $param (@mandatory) {
716 unless ( defined( $params->{$param} ) ) {
717 Koha::Exceptions::MissingParameter->throw(
718 error => "The $param parameter is mandatory" );
722 Koha::Exceptions::Item::Transfer::Limit->throw()
723 unless ( $params->{ignore_limits}
724 || $self->can_be_transferred( { to => $params->{to} } ) );
726 my $request = $self->get_transfer;
727 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
728 if ( $request && !$params->{enqueue} && !$params->{replace} );
730 $request->cancel( { reason => $params->{reason}, force => 1 } )
731 if ( defined($request) && $params->{replace} );
733 my $transfer = Koha::Item::Transfer->new(
735 itemnumber => $self->itemnumber,
736 daterequested => dt_from_string,
737 frombranch => $self->holdingbranch,
738 tobranch => $params->{to}->branchcode,
739 reason => $params->{reason},
740 comments => $params->{comment}
749 my $transfer = $item->get_transfer;
751 Return the active transfer request or undef
753 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
754 whereby the most recently sent, but not received, transfer will be returned
755 if it exists, otherwise the oldest unsatisfied transfer will be returned.
757 This allows for transfers to queue, which is the case for stock rotation and
758 rotating collections where a manual transfer may need to take precedence but
759 we still expect the item to end up at a final location eventually.
766 my $transfer = $self->_result->current_branchtransfers->next;
767 return Koha::Item::Transfer->_new_from_dbic($transfer) if $transfer;
771 return shift->get_transfer(@_);
776 my $transfer = $item->get_transfers;
778 Return the list of outstanding transfers (i.e requested but not yet cancelled
781 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
782 whereby the most recently sent, but not received, transfer will be returned
783 first if it exists, otherwise requests are in oldest to newest request order.
785 This allows for transfers to queue, which is the case for stock rotation and
786 rotating collections where a manual transfer may need to take precedence but
787 we still expect the item to end up at a final location eventually.
794 my $transfer_rs = $self->_result->current_branchtransfers;
796 return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
799 =head3 last_returned_by
801 Gets and sets the last patron to return an item.
803 Accepts a patron's id (borrowernumber) and returns Koha::Patron objects
805 $item->last_returned_by( $borrowernumber );
807 my $patron = $item->last_returned_by();
811 sub last_returned_by {
812 my ( $self, $borrowernumber ) = @_;
813 if ( $borrowernumber ) {
814 $self->_result->update_or_create_related('last_returned_by',
815 { borrowernumber => $borrowernumber, itemnumber => $self->itemnumber } );
817 my $rs = $self->_result->last_returned_by;
819 return Koha::Patron->_new_from_dbic($rs->borrowernumber);
822 =head3 can_article_request
824 my $bool = $item->can_article_request( $borrower )
826 Returns true if item can be specifically requested
828 $borrower must be a Koha::Patron object
832 sub can_article_request {
833 my ( $self, $borrower ) = @_;
835 my $rule = $self->article_request_type($borrower);
837 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
841 =head3 hidden_in_opac
843 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
845 Returns true if item fields match the hidding criteria defined in $rules.
846 Returns false otherwise.
848 Takes HASHref that can have the following parameters:
850 $rules : { <field> => [ value_1, ... ], ... }
852 Note: $rules inherits its structure from the parsed YAML from reading
853 the I<OpacHiddenItems> system preference.
858 my ( $self, $params ) = @_;
860 my $rules = $params->{rules} // {};
863 if C4::Context->preference('hidelostitems') and
866 my $hidden_in_opac = 0;
868 foreach my $field ( keys %{$rules} ) {
870 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
876 return $hidden_in_opac;
879 =head3 can_be_transferred
881 $item->can_be_transferred({ to => $to_library, from => $from_library })
882 Checks if an item can be transferred to given library.
884 This feature is controlled by two system preferences:
885 UseBranchTransferLimits to enable / disable the feature
886 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
887 for setting the limitations
889 Takes HASHref that can have the following parameters:
890 MANDATORY PARAMETERS:
893 $from : Koha::Library # if not given, item holdingbranch
894 # will be used instead
896 Returns 1 if item can be transferred to $to_library, otherwise 0.
898 To find out whether at least one item of a Koha::Biblio can be transferred, please
899 see Koha::Biblio->can_be_transferred() instead of using this method for
900 multiple items of the same biblio.
904 sub can_be_transferred {
905 my ($self, $params) = @_;
907 my $to = $params->{to};
908 my $from = $params->{from};
910 $to = $to->branchcode;
911 $from = defined $from ? $from->branchcode : $self->holdingbranch;
913 return 1 if $from eq $to; # Transfer to current branch is allowed
914 return 1 unless C4::Context->preference('UseBranchTransferLimits');
916 my $limittype = C4::Context->preference('BranchTransferLimitsType');
917 return Koha::Item::Transfer::Limits->search({
920 $limittype => $limittype eq 'itemtype'
921 ? $self->effective_itemtype : $self->ccode
926 =head3 pickup_locations
928 my $pickup_locations = $item->pickup_locations({ patron => $patron })
930 Returns possible pickup locations for this item, according to patron's home library
931 and if item can be transferred to each pickup location.
933 Throws a I<Koha::Exceptions::MissingParameter> exception if the B<mandatory> parameter I<patron>
938 sub pickup_locations {
939 my ($self, $params) = @_;
941 Koha::Exceptions::MissingParameter->throw( parameter => 'patron' )
942 unless exists $params->{patron};
944 my $patron = $params->{patron};
946 my $circ_control_branch = Koha::Policy::Holds->holds_control_library( $self, $patron );
948 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
950 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
951 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
953 my $pickup_libraries = Koha::Libraries->search();
954 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
955 $pickup_libraries = $self->home_branch->get_hold_libraries;
956 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
957 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
958 $pickup_libraries = $plib->get_hold_libraries;
959 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
960 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
961 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
962 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
965 return $pickup_libraries->search(
970 order_by => ['branchname']
972 ) unless C4::Context->preference('UseBranchTransferLimits');
974 my $limittype = C4::Context->preference('BranchTransferLimitsType');
975 my ($ccode, $itype) = (undef, undef);
976 if( $limittype eq 'ccode' ){
977 $ccode = $self->ccode;
979 $itype = $self->itype;
981 my $limits = Koha::Item::Transfer::Limits->search(
983 fromBranch => $self->holdingbranch,
987 { columns => ['toBranch'] }
990 return $pickup_libraries->search(
992 pickup_location => 1,
994 '-not_in' => $limits->_resultset->as_query
998 order_by => ['branchname']
1003 =head3 article_request_type
1005 my $type = $item->article_request_type( $borrower )
1007 returns 'yes', 'no', 'bib_only', or 'item_only'
1009 $borrower must be a Koha::Patron object
1013 sub article_request_type {
1014 my ( $self, $borrower ) = @_;
1016 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
1018 $branch_control eq 'homebranch' ? $self->homebranch
1019 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
1021 my $borrowertype = $borrower->categorycode;
1022 my $itemtype = $self->effective_itemtype();
1023 my $rule = Koha::CirculationRules->get_effective_rule(
1025 rule_name => 'article_requests',
1026 categorycode => $borrowertype,
1027 itemtype => $itemtype,
1028 branchcode => $branchcode
1032 return q{} unless $rule;
1033 return $rule->rule_value || q{}
1036 =head3 current_holds
1042 my $attributes = { order_by => 'priority' };
1043 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1045 itemnumber => $self->itemnumber,
1048 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
1049 waitingdate => { '!=' => undef },
1052 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
1053 return Koha::Holds->_new_from_dbic($hold_rs);
1058 return $self->current_holds->next;
1061 =head3 stockrotationitem
1063 my $sritem = Koha::Item->stockrotationitem;
1065 Returns the stock rotation item associated with the current item.
1069 sub stockrotationitem {
1071 my $rs = $self->_result->stockrotationitem;
1073 return Koha::StockRotationItem->_new_from_dbic( $rs );
1078 my $item = $item->add_to_rota($rota_id);
1080 Add this item to the rota identified by $ROTA_ID, which means associating it
1081 with the first stage of that rota. Should this item already be associated
1082 with a rota, then we will move it to the new rota.
1087 my ( $self, $rota_id ) = @_;
1088 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
1092 =head3 has_pending_hold
1094 my $is_pending_hold = $item->has_pending_hold();
1096 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
1100 sub has_pending_hold {
1102 return $self->_result->tmp_holdsqueue ? 1 : 0;
1105 =head3 has_pending_recall {
1107 my $has_pending_recall
1109 Return if whether has pending recall of not.
1113 sub has_pending_recall {
1116 # FIXME Must be moved to $self->recalls
1117 return Koha::Recalls->search(
1119 item_id => $self->itemnumber,
1120 status => 'waiting',
1125 =head3 as_marc_field
1127 my $field = $item->as_marc_field;
1129 This method returns a MARC::Field object representing the Koha::Item object
1130 with the current mappings configuration.
1137 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1139 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
1143 my $item_field = $tagslib->{$itemtag};
1145 my $more_subfields = $self->additional_attributes->to_hashref;
1146 foreach my $subfield (
1148 $a->{display_order} <=> $b->{display_order}
1149 || $a->{subfield} cmp $b->{subfield}
1150 } grep { ref($_) && %$_ } values %$item_field
1153 my $kohafield = $subfield->{kohafield};
1154 my $tagsubfield = $subfield->{tagsubfield};
1156 if ( defined $kohafield && $kohafield ne '' ) {
1157 next if $kohafield !~ m{^items\.}; # That would be weird!
1158 ( my $attribute = $kohafield ) =~ s|^items\.||;
1159 $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
1160 if defined $self->$attribute and $self->$attribute ne '';
1162 $value = $more_subfields->{$tagsubfield}
1165 next unless defined $value
1168 if ( $subfield->{repeatable} ) {
1169 my @values = split '\|', $value;
1170 push @subfields, ( $tagsubfield => $_ ) for @values;
1173 push @subfields, ( $tagsubfield => $value );
1178 return unless @subfields;
1180 return MARC::Field->new(
1181 "$itemtag", ' ', ' ', @subfields
1185 =head3 renewal_branchcode
1187 Returns the branchcode to be recorded in statistics renewal of the item
1191 sub renewal_branchcode {
1193 my ( $self, $params ) = @_;
1195 my $interface = C4::Context->interface;
1197 my $renewal_branchcode;
1199 if ( $interface eq 'opac' ) {
1200 $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1201 if ( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ) {
1202 $branchcode = 'OPACRenew';
1204 } elsif ( $interface eq 'api' ) {
1205 $renewal_branchcode = C4::Context->preference('RESTAPIRenewalBranch');
1206 if ( !defined $renewal_branchcode || $renewal_branchcode eq 'apirenew' ) {
1207 $branchcode = 'APIRenew';
1211 return $branchcode if $branchcode;
1213 if ($renewal_branchcode) {
1214 if ( $renewal_branchcode eq 'itemhomebranch' ) {
1215 $branchcode = $self->homebranch;
1216 } elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1217 $branchcode = $self->checkout->patron->branchcode;
1218 } elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1219 $branchcode = $self->checkout->branchcode;
1220 } elsif ( $renewal_branchcode eq 'apiuserbranch' ) {
1222 ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1223 ? C4::Context->userenv->{branch}
1224 : $params->{branch};
1230 ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1231 ? C4::Context->userenv->{branch}
1232 : $params->{branch};
1239 Return the cover images associated with this item.
1246 my $cover_images_rs = $self->_result->cover_images;
1247 return Koha::CoverImages->_new_from_dbic($cover_images_rs);
1250 =head3 cover_image_ids
1252 Return the cover image ids associated with this item.
1256 sub cover_image_ids {
1258 return [ $self->cover_images->get_column('imagenumber') ];
1261 =head3 columns_to_str
1263 my $values = $items->columns_to_str;
1265 Return a hashref with the string representation of the different attribute of the item.
1267 This is meant to be used for display purpose only.
1271 sub columns_to_str {
1273 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
1274 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
1275 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1277 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1280 for my $column ( @{$self->_columns}) {
1282 next if $column eq 'more_subfields_xml';
1284 my $value = $self->$column;
1285 # 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
1287 if ( not defined $value or $value eq "" ) {
1288 $values->{$column} = $value;
1293 exists $mss->{"items.$column"}
1294 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1297 $values->{$column} =
1299 ? $subfield->{authorised_value}
1300 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1301 $subfield->{tagsubfield}, $value, '', $tagslib )
1307 $self->more_subfields_xml
1308 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1313 my ( $field ) = $marc_more->fields;
1314 for my $sf ( $field->subfields ) {
1315 my $subfield_code = $sf->[0];
1316 my $value = $sf->[1];
1317 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1318 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1320 $subfield->{authorised_value}
1321 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1322 $subfield->{tagsubfield}, $value, '', $tagslib )
1325 push @{$more_values->{$subfield_code}}, $value;
1328 while ( my ( $k, $v ) = each %$more_values ) {
1329 $values->{$k} = join ' | ', @$v;
1336 =head3 additional_attributes
1338 my $attributes = $item->additional_attributes;
1339 $attributes->{k} = 'new k';
1340 $item->update({ more_subfields => $attributes->to_marcxml });
1342 Returns a Koha::Item::Attributes object that represents the non-mapped
1343 attributes for this item.
1347 sub additional_attributes {
1350 return Koha::Item::Attributes->new_from_marcxml(
1351 $self->more_subfields_xml,
1355 =head3 _set_found_trigger
1357 $self->_set_found_trigger
1359 Finds the most recent lost item charge for this item and refunds the patron
1360 appropriately, taking into account any payments or writeoffs already applied
1363 Internal function, not exported, called only by Koha::Item->store.
1367 sub _set_found_trigger {
1368 my ( $self, $pre_mod_item ) = @_;
1370 # Reverse any lost item charges if necessary.
1371 my $no_refund_after_days =
1372 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1373 if ($no_refund_after_days) {
1374 my $today = dt_from_string();
1375 my $lost_age_in_days =
1376 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1379 return $self unless $lost_age_in_days < $no_refund_after_days;
1382 my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
1385 return_branch => C4::Context->userenv
1386 ? C4::Context->userenv->{'branch'}
1390 my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
1392 if ( $lostreturn_policy ) {
1394 # refund charge made for lost book
1395 my $lost_charge = Koha::Account::Lines->search(
1397 itemnumber => $self->itemnumber,
1398 debit_type_code => 'LOST',
1399 status => [ undef, { '<>' => 'FOUND' } ]
1402 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1407 if ( $lost_charge ) {
1409 my $patron = $lost_charge->patron;
1412 my $account = $patron->account;
1414 # Credit outstanding amount
1415 my $credit_total = $lost_charge->amountoutstanding;
1419 $lost_charge->amount > $lost_charge->amountoutstanding &&
1420 $lostreturn_policy ne "refund_unpaid"
1422 # some amount has been cancelled. collect the offsets that are not writeoffs
1423 # this works because the only way to subtract from this kind of a debt is
1424 # using the UI buttons 'Pay' and 'Write off'
1426 # We don't credit any payments if return policy is
1429 # In that case only unpaid/outstanding amount
1430 # will be credited which settles the debt without
1431 # creating extra credits
1433 my $credit_offsets = $lost_charge->debit_offsets(
1435 'credit_id' => { '!=' => undef },
1436 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1438 { join => 'credit' }
1441 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1442 # credits are negative on the DB
1443 $credit_offsets->total * -1 :
1445 # Credit the outstanding amount, then add what has been
1446 # paid to create a net credit for this amount
1447 $credit_total += $total_to_refund;
1451 if ( $credit_total > 0 ) {
1453 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1454 $credit = $account->add_credit(
1456 amount => $credit_total,
1457 description => 'Item found ' . $self->itemnumber,
1458 type => 'LOST_FOUND',
1459 interface => C4::Context->interface,
1460 library_id => $branchcode,
1461 item_id => $self->itemnumber,
1462 issue_id => $lost_charge->issue_id
1466 $credit->apply( { debits => [$lost_charge] } );
1470 message => 'lost_refunded',
1471 payload => { credit_id => $credit->id }
1476 # Update the account status
1477 $lost_charge->status('FOUND');
1478 $lost_charge->store();
1480 # Reconcile balances if required
1481 if ( C4::Context->preference('AccountAutoReconcile') ) {
1482 $account->reconcile_balance;
1487 # possibly restore fine for lost book
1488 my $lost_overdue = Koha::Account::Lines->search(
1490 itemnumber => $self->itemnumber,
1491 debit_type_code => 'OVERDUE',
1495 order_by => { '-desc' => 'date' },
1499 if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
1501 my $patron = $lost_overdue->patron;
1503 my $account = $patron->account;
1505 # Update status of fine
1506 $lost_overdue->status('FOUND')->store();
1508 # Find related forgive credit
1509 my $refund = $lost_overdue->credits(
1511 credit_type_code => 'FORGIVEN',
1512 itemnumber => $self->itemnumber,
1513 status => [ { '!=' => 'VOID' }, undef ]
1515 { order_by => { '-desc' => 'date' }, rows => 1 }
1519 # Revert the forgive credit
1520 $refund->void({ interface => 'trigger' });
1524 message => 'lost_restored',
1525 payload => { refund_id => $refund->id }
1530 # Reconcile balances if required
1531 if ( C4::Context->preference('AccountAutoReconcile') ) {
1532 $account->reconcile_balance;
1536 } elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
1540 message => 'lost_charge',
1546 my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
1548 if ( $processingreturn_policy ) {
1550 # refund processing charge made for lost book
1551 my $processing_charge = Koha::Account::Lines->search(
1553 itemnumber => $self->itemnumber,
1554 debit_type_code => 'PROCESSING',
1555 status => [ undef, { '<>' => 'FOUND' } ]
1558 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1563 if ( $processing_charge ) {
1565 my $patron = $processing_charge->patron;
1568 my $account = $patron->account;
1570 # Credit outstanding amount
1571 my $credit_total = $processing_charge->amountoutstanding;
1575 $processing_charge->amount > $processing_charge->amountoutstanding &&
1576 $processingreturn_policy ne "refund_unpaid"
1578 # some amount has been cancelled. collect the offsets that are not writeoffs
1579 # this works because the only way to subtract from this kind of a debt is
1580 # using the UI buttons 'Pay' and 'Write off'
1582 # We don't credit any payments if return policy is
1585 # In that case only unpaid/outstanding amount
1586 # will be credited which settles the debt without
1587 # creating extra credits
1589 my $credit_offsets = $processing_charge->debit_offsets(
1591 'credit_id' => { '!=' => undef },
1592 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1594 { join => 'credit' }
1597 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1598 # credits are negative on the DB
1599 $credit_offsets->total * -1 :
1601 # Credit the outstanding amount, then add what has been
1602 # paid to create a net credit for this amount
1603 $credit_total += $total_to_refund;
1607 if ( $credit_total > 0 ) {
1609 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1610 $credit = $account->add_credit(
1612 amount => $credit_total,
1613 description => 'Item found ' . $self->itemnumber,
1614 type => 'PROCESSING_FOUND',
1615 interface => C4::Context->interface,
1616 library_id => $branchcode,
1617 item_id => $self->itemnumber,
1618 issue_id => $processing_charge->issue_id
1622 $credit->apply( { debits => [$processing_charge] } );
1626 message => 'processing_refunded',
1627 payload => { credit_id => $credit->id }
1632 # Update the account status
1633 $processing_charge->status('FOUND');
1634 $processing_charge->store();
1636 # Reconcile balances if required
1637 if ( C4::Context->preference('AccountAutoReconcile') ) {
1638 $account->reconcile_balance;
1647 =head3 public_read_list
1649 This method returns the list of publicly readable database fields for both API and UI output purposes
1653 sub public_read_list {
1655 'itemnumber', 'biblionumber', 'homebranch',
1656 'holdingbranch', 'location', 'collectioncode',
1657 'itemcallnumber', 'copynumber', 'enumchron',
1658 'barcode', 'dateaccessioned', 'itemnotes',
1659 'onloan', 'uri', 'itype',
1660 'notforloan', 'damaged', 'itemlost',
1661 'withdrawn', 'restricted'
1667 Overloaded to_api method to ensure item-level itypes is adhered to.
1672 my ($self, $params) = @_;
1674 my $response = $self->SUPER::to_api($params);
1677 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1679 my $itype_notforloan = $self->itemtype->notforloan;
1680 $overrides->{effective_not_for_loan_status} =
1681 ( defined $itype_notforloan && !$self->notforloan ) ? $itype_notforloan : $self->notforloan;
1683 return { %$response, %$overrides };
1686 =head3 to_api_mapping
1688 This method returns the mapping for representing a Koha::Item object
1693 sub to_api_mapping {
1695 itemnumber => 'item_id',
1696 biblionumber => 'biblio_id',
1697 biblioitemnumber => undef,
1698 barcode => 'external_id',
1699 dateaccessioned => 'acquisition_date',
1700 booksellerid => 'acquisition_source',
1701 homebranch => 'home_library_id',
1702 price => 'purchase_price',
1703 replacementprice => 'replacement_price',
1704 replacementpricedate => 'replacement_price_date',
1705 datelastborrowed => 'last_checkout_date',
1706 datelastseen => 'last_seen_date',
1708 notforloan => 'not_for_loan_status',
1709 damaged => 'damaged_status',
1710 damaged_on => 'damaged_date',
1711 itemlost => 'lost_status',
1712 itemlost_on => 'lost_date',
1713 withdrawn => 'withdrawn',
1714 withdrawn_on => 'withdrawn_date',
1715 itemcallnumber => 'callnumber',
1716 coded_location_qualifier => 'coded_location_qualifier',
1717 issues => 'checkouts_count',
1718 renewals => 'renewals_count',
1719 reserves => 'holds_count',
1720 restricted => 'restricted_status',
1721 itemnotes => 'public_notes',
1722 itemnotes_nonpublic => 'internal_notes',
1723 holdingbranch => 'holding_library_id',
1724 timestamp => 'timestamp',
1725 location => 'location',
1726 permanent_location => 'permanent_location',
1727 onloan => 'checked_out_date',
1728 cn_source => 'call_number_source',
1729 cn_sort => 'call_number_sort',
1730 ccode => 'collection_code',
1731 materials => 'materials_notes',
1733 itype => 'item_type_id',
1734 more_subfields_xml => 'extended_subfields',
1735 enumchron => 'serial_issue_number',
1736 copynumber => 'copy_number',
1737 stocknumber => 'inventory_number',
1738 new_status => 'new_status',
1739 deleted_on => undef,
1745 my $itemtype = $item->itemtype;
1747 Returns Koha object for effective itemtype
1754 return Koha::ItemTypes->find( $self->effective_itemtype );
1757 return shift->itemtype;
1762 my $orders = $item->orders();
1764 Returns a Koha::Acquisition::Orders object
1771 my $orders = $self->_result->item_orders;
1772 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1775 =head3 tracked_links
1777 my $tracked_links = $item->tracked_links();
1779 Returns a Koha::TrackedLinks object
1786 my $tracked_links = $self->_result->linktrackers;
1787 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1790 =head3 move_to_biblio
1792 $item->move_to_biblio($to_biblio[, $params]);
1794 Move the item to another biblio and update any references in other tables.
1796 The final optional parameter, C<$params>, is expected to contain the
1797 'skip_record_index' key, which is relayed down to Koha::Item->store.
1798 There it prevents calling index_records, which takes most of the
1799 time in batch adds/deletes. The caller must take care of calling
1800 index_records separately.
1803 skip_record_index => 1|0
1805 Returns undef if the move failed or the biblionumber of the destination record otherwise
1809 sub move_to_biblio {
1810 my ( $self, $to_biblio, $params ) = @_;
1814 return if $self->biblionumber == $to_biblio->biblionumber;
1816 my $from_biblionumber = $self->biblionumber;
1817 my $to_biblionumber = $to_biblio->biblionumber;
1819 # Own biblionumber and biblioitemnumber
1821 biblionumber => $to_biblionumber,
1822 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1823 })->store({ skip_record_index => $params->{skip_record_index} });
1825 unless ($params->{skip_record_index}) {
1826 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1827 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1830 # Acquisition orders
1831 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1834 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1836 # hold_fill_target (there's no Koha object available yet)
1837 my $hold_fill_target = $self->_result->hold_fill_target;
1838 if ($hold_fill_target) {
1839 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1842 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1843 # and can't even fake one since the significant columns are nullable.
1844 my $storage = $self->_result->result_source->storage;
1847 my ($storage, $dbh, @cols) = @_;
1849 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1854 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1856 return $to_biblionumber;
1861 my $bundle_items = $item->bundle_items;
1863 Returns the items associated with this bundle
1870 my $rs = $self->_result->bundle_items;
1871 return Koha::Items->_new_from_dbic($rs);
1876 my $is_bundle = $item->is_bundle;
1878 Returns whether the item is a bundle or not
1884 return $self->bundle_items->count ? 1 : 0;
1889 my $bundle = $item->bundle_host;
1891 Returns the bundle item this item is attached to
1898 my $bundle_items_rs = $self->_result->item_bundles_item;
1899 return unless $bundle_items_rs;
1900 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1905 my $in_bundle = $item->in_bundle;
1907 Returns whether this item is currently in a bundle
1913 return $self->bundle_host ? 1 : 0;
1916 =head3 add_to_bundle
1918 my $link = $item->add_to_bundle($bundle_item);
1920 Adds the bundle_item passed to this item
1925 my ( $self, $bundle_item, $options ) = @_;
1929 Koha::Exceptions::Item::Bundle::IsBundle->throw()
1930 if ( $self->itemnumber eq $bundle_item->itemnumber
1931 || $bundle_item->is_bundle
1932 || $self->in_bundle );
1934 my $schema = Koha::Database->new->schema;
1936 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1942 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
1944 my $checkout = $bundle_item->checkout;
1946 unless ($options->{force_checkin}) {
1947 Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
1950 my $branchcode = C4::Context->userenv->{'branch'};
1951 my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
1953 Koha::Exceptions::Checkin::FailedCheckin->throw();
1957 my $holds = $bundle_item->current_holds;
1958 if ($holds->count) {
1959 unless ($options->{ignore_holds}) {
1960 Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
1964 $self->_result->add_to_item_bundles_hosts(
1965 { item => $bundle_item->itemnumber } );
1967 $bundle_item->notforloan($BundleNotLoanValue)->store();
1973 # 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
1974 if ( ref($_) eq 'DBIx::Class::Exception' ) {
1975 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1977 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1978 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1979 Koha::Exceptions::Object::FKConstraint->throw(
1980 error => 'Broken FK constraint',
1981 broken_fk => $+{column}
1986 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
1988 Koha::Exceptions::Object::DuplicateID->throw(
1989 error => 'Duplicate ID',
1990 duplicate_id => $+{key}
1993 elsif ( $_->{msg} =~
1994 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
1996 { # The optional \W in the regex might be a quote or backtick
1997 my $type = $+{type};
1998 my $value = $+{value};
1999 my $property = $+{property};
2000 $property =~ s/['`]//g;
2001 Koha::Exceptions::Object::BadValue->throw(
2004 property => $property =~ /(\w+\.\w+)$/
2007 , # results in table.column without quotes or backtics
2011 # Catch-all for foreign key breakages. It will help find other use cases
2020 =head3 remove_from_bundle
2022 Remove this item from any bundle it may have been attached to.
2026 sub remove_from_bundle {
2029 my $bundle_host = $self->bundle_host;
2031 return 0 unless $bundle_host; # Should not we raise an exception here?
2033 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $bundle_host->checkout;
2035 my $bundle_item_rs = $self->_result->item_bundles_item;
2036 if ( $bundle_item_rs ) {
2037 $bundle_item_rs->delete;
2038 $self->notforloan(0)->store();
2044 =head2 Internal methods
2046 =head3 _after_item_action_hooks
2048 Helper method that takes care of calling all plugin hooks
2052 sub _after_item_action_hooks {
2053 my ( $self, $params ) = @_;
2055 my $action = $params->{action};
2057 Koha::Plugins->call(
2058 'after_item_action',
2062 item_id => $self->itemnumber,
2069 my $recall = $item->recall;
2071 Return the relevant recall for this item
2077 my @recalls = Koha::Recalls->search(
2079 biblio_id => $self->biblionumber,
2082 { order_by => { -asc => 'created_date' } }
2085 my $item_level_recall;
2086 foreach my $recall (@recalls) {
2087 if ( $recall->item_level ) {
2088 $item_level_recall = 1;
2089 if ( $recall->item_id == $self->itemnumber ) {
2094 if ($item_level_recall) {
2096 # recall needs to be filled be a specific item only
2097 # no other item is relevant to return
2101 # no item-level recall to return, so return earliest biblio-level
2102 # FIXME: eventually this will be based on priority
2106 =head3 can_be_recalled
2108 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
2110 Does item-level checks and returns if items can be recalled by this borrower
2114 sub can_be_recalled {
2115 my ( $self, $params ) = @_;
2117 return 0 if !( C4::Context->preference('UseRecalls') );
2119 # check if this item is not for loan, withdrawn or lost
2120 return 0 if ( $self->notforloan != 0 );
2121 return 0 if ( $self->itemlost != 0 );
2122 return 0 if ( $self->withdrawn != 0 );
2124 # check if this item is not checked out - if not checked out, can't be recalled
2125 return 0 if ( !defined( $self->checkout ) );
2127 my $patron = $params->{patron};
2129 my $branchcode = C4::Context->userenv->{'branch'};
2131 $branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
2134 # Check the circulation rule for each relevant itemtype for this item
2135 my $rule = Koha::CirculationRules->get_effective_rules({
2136 branchcode => $branchcode,
2137 categorycode => $patron ? $patron->categorycode : undef,
2138 itemtype => $self->effective_itemtype,
2141 'recalls_per_record',
2146 # check recalls allowed has been set and is not zero
2147 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2150 # check borrower has not reached open recalls allowed limit
2151 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
2153 # check borrower has not reach open recalls allowed per record limit
2154 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
2156 # check if this patron has already recalled this item
2157 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
2159 # check if this patron has already checked out this item
2160 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2162 # check if this patron has already reserved this item
2163 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2166 # check item availability
2167 # items are unavailable for recall if they are lost, withdrawn or notforloan
2168 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
2170 # if there are no available items at all, no recall can be placed
2171 return 0 if ( scalar @items == 0 );
2173 my $checked_out_count = 0;
2175 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
2178 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
2179 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
2181 # can't recall if no items have been checked out
2182 return 0 if ( $checked_out_count == 0 );
2188 =head3 can_be_waiting_recall
2190 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
2192 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
2193 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
2197 sub can_be_waiting_recall {
2200 return 0 if !( C4::Context->preference('UseRecalls') );
2202 # check if this item is not for loan, withdrawn or lost
2203 return 0 if ( $self->notforloan != 0 );
2204 return 0 if ( $self->itemlost != 0 );
2205 return 0 if ( $self->withdrawn != 0 );
2207 my $branchcode = $self->holdingbranch;
2208 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
2209 $branchcode = C4::Context->userenv->{'branch'};
2211 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
2214 # Check the circulation rule for each relevant itemtype for this item
2215 my $most_relevant_recall = $self->check_recalls;
2216 my $rule = Koha::CirculationRules->get_effective_rules(
2218 branchcode => $branchcode,
2219 categorycode => $most_relevant_recall ? $most_relevant_recall->patron->categorycode : undef,
2220 itemtype => $self->effective_itemtype,
2221 rules => [ 'recalls_allowed', ],
2225 # check recalls allowed has been set and is not zero
2226 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2232 =head3 check_recalls
2234 my $recall = $item->check_recalls;
2236 Get the most relevant recall for this item.
2243 my @recalls = Koha::Recalls->search(
2244 { biblio_id => $self->biblionumber,
2245 item_id => [ $self->itemnumber, undef ]
2247 { order_by => { -asc => 'created_date' } }
2248 )->filter_by_current->as_list;
2251 # iterate through relevant recalls to find the best one.
2252 # if we come across a waiting recall, use this one.
2253 # 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.
2254 foreach my $r ( @recalls ) {
2255 if ( $r->waiting ) {
2260 unless ( defined $recall ) {
2261 $recall = $recalls[0];
2267 =head3 is_notforloan
2269 my $is_notforloan = $item->is_notforloan;
2271 Determine whether or not this item is "notforloan" based on
2272 the item's notforloan status or its item type
2278 my $is_notforloan = 0;
2280 if ( $self->notforloan ){
2284 my $itemtype = $self->itemtype;
2286 if ( $itemtype->notforloan ){
2292 return $is_notforloan;
2295 =head3 is_denied_renewal
2297 my $is_denied_renewal = $item->is_denied_renewal;
2299 Determine whether or not this item can be renewed based on the
2300 rules set in the ItemsDeniedRenewal system preference.
2304 sub is_denied_renewal {
2306 my $denyingrules = C4::Context->yaml_preference('ItemsDeniedRenewal');
2307 return 0 unless $denyingrules;
2308 foreach my $field (keys %$denyingrules) {
2309 # Silently ignore bad column names; TODO we should validate elsewhere
2310 next if !$self->_result->result_source->has_column($field);
2311 my $val = $self->$field;
2312 if( !defined $val) {
2313 if ( any { !defined $_ } @{$denyingrules->{$field}} ){
2316 } elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
2317 # If the results matches the values in the syspref
2318 # We return true if match found
2327 Returns a map of column name to string representations including the string,
2328 the mapping type and the mapping category where appropriate.
2330 Currently handles authorised value mappings, library, callnumber and itemtype
2333 Accepts a param hashref where the 'public' key denotes whether we want the public
2334 or staff client strings.
2339 my ( $self, $params ) = @_;
2340 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
2341 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
2342 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
2344 my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
2346 # Hardcoded known 'authorised_value' values mapped to API codes
2347 my $code_to_type = {
2348 branches => 'library',
2349 cn_source => 'call_number_source',
2350 itemtypes => 'item_type',
2353 # Handle not null and default values for integers and dates
2356 foreach my $col ( @{$self->_columns} ) {
2358 # By now, we are done with known columns, now check the framework for mappings
2359 my $field = $self->_result->result_source->name . '.' . $col;
2361 # Check there's an entry in the MARC subfield structure for the field
2362 if ( exists $mss->{$field}
2363 && scalar @{ $mss->{$field} } > 0
2364 && $mss->{$field}[0]->{authorised_value} )
2366 my $subfield = $mss->{$field}[0];
2367 my $code = $subfield->{authorised_value};
2369 my $str = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
2370 my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
2371 $strings->{$col} = {
2374 ( $type eq 'av' ? ( category => $code ) : () ),
2383 =head3 location_update_trigger
2385 $item->location_update_trigger( $action );
2387 Updates the item location based on I<$action>. It is done like this:
2391 =item For B<checkin>, location is updated following the I<UpdateItemLocationOnCheckin> preference.
2393 =item For B<checkout>, location is updated following the I<UpdateItemLocationOnCheckout> preference.
2397 FIXME: It should return I<$self>. See bug 35270.
2401 sub location_update_trigger {
2402 my ( $self, $action ) = @_;
2404 my ( $update_loc_rules, $messages );
2405 if ( $action eq 'checkin' ) {
2406 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckin');
2408 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckout');
2411 if ($update_loc_rules) {
2412 if ( defined $update_loc_rules->{_ALL_} ) {
2413 if ( $update_loc_rules->{_ALL_} eq '_PERM_' ) {
2414 $update_loc_rules->{_ALL_} = $self->permanent_location;
2416 if ( $update_loc_rules->{_ALL_} eq '_BLANK_' ) {
2417 $update_loc_rules->{_ALL_} = '';
2420 ( defined $self->location && $self->location ne $update_loc_rules->{_ALL_} )
2421 || ( !defined $self->location
2422 && $update_loc_rules->{_ALL_} ne "" )
2425 $messages->{'ItemLocationUpdated'} =
2426 { from => $self->location, to => $update_loc_rules->{_ALL_} };
2427 $self->location( $update_loc_rules->{_ALL_} )->store(
2430 skip_record_index => 1,
2431 skip_holds_queue => 1
2436 foreach my $key ( keys %$update_loc_rules ) {
2437 if ( $update_loc_rules->{$key} eq '_PERM_' ) {
2438 $update_loc_rules->{$key} = $self->permanent_location;
2439 } elsif ( $update_loc_rules->{$key} eq '_BLANK_' ) {
2440 $update_loc_rules->{$key} = '';
2444 defined $self->location
2445 && $self->location eq $key
2446 && $self->location ne $update_loc_rules->{$key}
2448 || ( $key eq '_BLANK_'
2449 && ( !defined $self->location || $self->location eq '' )
2450 && $update_loc_rules->{$key} ne '' )
2453 $messages->{'ItemLocationUpdated'} = {
2454 from => $self->location,
2455 to => $update_loc_rules->{$key}
2457 $self->location( $update_loc_rules->{$key} )->store(
2460 skip_record_index => 1,
2461 skip_holds_queue => 1
2482 Kyle M Hall <kyle@bywatersolutions.com>