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::StockRotationItem;
55 use Koha::StockRotationRotas;
56 use Koha::TrackedLinks;
57 use Koha::Policy::Holds;
59 use base qw(Koha::Object);
63 Koha::Item - Koha Item object class
75 $params can take an optional 'skip_record_index' parameter.
76 If set, the reindexation process will not happen (index_records not called)
77 You should not turn it on if you do not understand what it is doing exactly.
83 my $params = @_ ? shift : {};
85 my $log_action = $params->{log_action} // 1;
87 # We do not want to oblige callers to pass this value
88 # Dev conveniences vs performance?
89 unless ( $self->biblioitemnumber ) {
90 $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
93 # See related changes from C4::Items::AddItem
94 unless ( $self->itype ) {
95 $self->itype($self->biblio->biblioitem->itemtype);
98 $self->barcode( C4::Circulation::barcodedecode( $self->barcode ) );
100 my $today = dt_from_string;
101 my $action = 'create';
103 unless ( $self->in_storage ) { #AddItem
105 unless ( $self->permanent_location ) {
106 $self->permanent_location($self->location);
109 my $default_location = C4::Context->preference('NewItemsDefaultLocation');
110 unless ( $self->location || !$default_location ) {
111 $self->permanent_location( $self->location || $default_location )
112 unless $self->permanent_location;
113 $self->location($default_location);
116 unless ( $self->replacementpricedate ) {
117 $self->replacementpricedate($today);
119 unless ( $self->datelastseen ) {
120 $self->datelastseen($today);
123 unless ( $self->dateaccessioned ) {
124 $self->dateaccessioned($today);
127 if ( $self->itemcallnumber
128 or $self->cn_source )
130 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
131 $self->cn_sort($cn_sort);
134 # should be quite rare when adding item
135 if ( $self->itemlost && $self->itemlost > 0 ) { # TODO BZ34308
136 $self->_add_statistic('item_lost');
143 my %updated_columns = $self->_result->get_dirty_columns;
144 return $self->SUPER::store unless %updated_columns;
146 # Retrieve the item for comparison if we need to
148 exists $updated_columns{itemlost}
149 or exists $updated_columns{withdrawn}
150 or exists $updated_columns{damaged}
151 ) ? $self->get_from_storage : undef;
153 # Update *_on fields if needed
154 # FIXME: Why not for AddItem as well?
155 my @fields = qw( itemlost withdrawn damaged );
156 for my $field (@fields) {
158 # If the field is defined but empty or 0, we are
159 # removing/unsetting and thus need to clear out
161 if ( exists $updated_columns{$field}
162 && defined( $self->$field )
165 my $field_on = "${field}_on";
166 $self->$field_on(undef);
168 # If the field has changed otherwise, we much update
170 elsif (exists $updated_columns{$field}
171 && $updated_columns{$field}
172 && !$pre_mod_item->$field )
174 my $field_on = "${field}_on";
175 $self->$field_on(dt_from_string);
179 if ( exists $updated_columns{itemcallnumber}
180 or exists $updated_columns{cn_source} )
182 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
183 $self->cn_sort($cn_sort);
187 if ( exists $updated_columns{location}
188 and ( !defined($self->location) or $self->location !~ /^(CART|PROC)$/ )
189 and not exists $updated_columns{permanent_location} )
191 $self->permanent_location( $self->location );
194 # TODO BZ 34308 (gt zero checks)
195 if ( exists $updated_columns{itemlost}
196 && ( !$updated_columns{itemlost} || $updated_columns{itemlost} <= 0 )
197 && ( $pre_mod_item->itemlost && $pre_mod_item->itemlost > 0 ) )
200 # reverse any list item charges if necessary
201 $self->_set_found_trigger($pre_mod_item);
202 $self->_add_statistic('item_found');
203 } elsif ( exists $updated_columns{itemlost}
204 && ( $updated_columns{itemlost} && $updated_columns{itemlost} > 0 )
205 && ( !$pre_mod_item->itemlost || $pre_mod_item->itemlost <= 0 ) )
208 $self->_add_statistic('item_lost');
212 my $result = $self->SUPER::store;
213 if ( $log_action && C4::Context->preference("CataloguingLog") ) {
215 ? logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
216 : logaction( "CATALOGUING", "MODIFY", $self->itemnumber, $self );
218 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
219 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
220 unless $params->{skip_record_index};
221 $self->get_from_storage->_after_item_action_hooks({ action => $action });
223 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
225 biblio_ids => [ $self->biblionumber ]
227 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
233 my ( $self, $type ) = @_;
234 C4::Stats::UpdateStats(
236 borrowernumber => undef,
237 branch => C4::Context->userenv ? C4::Context->userenv->{branch} : undef,
238 categorycode => undef,
239 ccode => $self->ccode,
240 itemnumber => $self->itemnumber,
241 itemtype => $self->effective_itemtype,
242 location => $self->location,
254 my $params = @_ ? shift : {};
256 # FIXME check the item has no current issues
257 # i.e. raise the appropriate exception
259 # Get the item group so we can delete it later if it has no items left
260 my $item_group = C4::Context->preference('EnableItemGroups') ? $self->item_group : undef;
262 my $result = $self->SUPER::delete;
264 # Delete the item group if it has no items left
265 $item_group->delete if ( $item_group && $item_group->items->count == 0 );
267 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
268 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
269 unless $params->{skip_record_index};
271 $self->_after_item_action_hooks({ action => 'delete' });
273 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
274 if C4::Context->preference("CataloguingLog");
276 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
278 biblio_ids => [ $self->biblionumber ]
280 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
291 my $params = @_ ? shift : {};
293 my $safe_to_delete = $self->safe_to_delete;
294 return $safe_to_delete unless $safe_to_delete;
296 $self->move_to_deleted;
298 return $self->delete($params);
301 =head3 safe_to_delete
303 returns 1 if the item is safe to delete,
305 "book_on_loan" if the item is checked out,
307 "not_same_branch" if the item is blocked by independent branches,
309 "book_reserved" if the there are holds aganst the item, or
311 "linked_analytics" if the item has linked analytic records.
313 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
322 $error = "book_on_loan" if $self->checkout;
324 $error //= "not_same_branch"
325 if defined C4::Context->userenv
326 and defined C4::Context->userenv->{number}
327 and !Koha::Patrons->find( C4::Context->userenv->{number} )->can_edit_items_from( $self->homebranch );
329 # check it doesn't have a waiting reserve
330 $error //= "book_reserved"
331 if $self->holds->filter_by_found->count;
333 $error //= "linked_analytics"
334 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
336 $error //= "last_item_for_hold"
337 if $self->biblio->items->count == 1
338 && $self->biblio->holds->search(
345 return Koha::Result::Boolean->new(0)->add_message({ message => $error });
348 return Koha::Result::Boolean->new(1);
351 =head3 move_to_deleted
353 my $is_moved = $item->move_to_deleted;
355 Move an item to the deleteditems table.
356 This can be done before deleting an item, to make sure the data are not completely deleted.
360 sub move_to_deleted {
362 my $item_infos = $self->unblessed;
363 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
364 $item_infos->{deleted_on} = dt_from_string;
365 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
368 =head3 effective_itemtype
370 Returns the itemtype for the item based on whether item level itemtypes are set or not.
374 sub effective_itemtype {
377 return $self->_result()->effective_itemtype();
387 my $hb_rs = $self->_result->homebranch;
389 return Koha::Library->_new_from_dbic( $hb_rs );
392 =head3 holding_branch
399 my $hb_rs = $self->_result->holdingbranch;
401 return Koha::Library->_new_from_dbic( $hb_rs );
406 my $biblio = $item->biblio;
408 Return the bibliographic record of this item
414 my $biblio_rs = $self->_result->biblio;
415 return Koha::Biblio->_new_from_dbic( $biblio_rs );
420 my $biblioitem = $item->biblioitem;
422 Return the biblioitem record of this item
428 my $biblioitem_rs = $self->_result->biblioitem;
429 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
434 my $checkout = $item->checkout;
436 Return the checkout for this item
442 my $checkout_rs = $self->_result->issue;
443 return unless $checkout_rs;
444 return Koha::Checkout->_new_from_dbic( $checkout_rs );
449 my $item_group = $item->item_group;
451 Return the item group for this item
458 my $item_group_item = $self->_result->item_group_item;
459 return unless $item_group_item;
461 my $item_group_rs = $item_group_item->item_group;
462 return unless $item_group_rs;
464 my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs );
470 my $return_claims = $item->return_claims;
472 Return any return_claims associated with this item
477 my ( $self, $params, $attrs ) = @_;
478 my $claims_rs = $self->_result->return_claims->search($params, $attrs);
479 return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs );
484 my $return_claim = $item->return_claim;
486 Returns the most recent unresolved return_claims associated with this item
493 $self->_result->return_claims->search( { resolution => undef },
494 { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single;
495 return unless $claims_rs;
496 return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs);
501 my $holds = $item->holds();
502 my $holds = $item->holds($params);
503 my $holds = $item->holds({ found => 'W'});
505 Return holds attached to an item, optionally accept a hashref of params to pass to search
510 my ( $self,$params ) = @_;
511 my $holds_rs = $self->_result->reserves->search($params);
512 return Koha::Holds->_new_from_dbic( $holds_rs );
517 my $bookings = $item->bookings();
519 Returns the bookings attached to this item.
524 my ( $self, $params ) = @_;
525 my $bookings_rs = $self->_result->bookings->search($params);
526 return Koha::Bookings->_new_from_dbic($bookings_rs);
531 my $booking = $item->find_booking( { checkout_date => $now, due_date => $future_date } );
533 Find the first booking that would conflict with the passed checkout dates for this item.
535 FIXME: This can be simplified, it was originally intended to iterate all biblio level bookings
536 to catch cases where this item may be the last available to satisfy a biblio level only booking.
537 However, we dropped the biblio level functionality prior to push as bugs were found in it's
543 my ( $self, $params ) = @_;
545 my $checkout_date = $params->{checkout_date};
546 my $due_date = $params->{due_date};
547 my $biblio = $self->biblio;
549 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
550 my $bookings = $biblio->bookings(
552 # Proposed checkout starts during booked period
555 $dtf->format_datetime($checkout_date),
556 $dtf->format_datetime($due_date)
560 # Proposed checkout is due during booked period
563 $dtf->format_datetime($checkout_date),
564 $dtf->format_datetime($due_date)
568 # Proposed checkout would contain the booked period
570 start_date => { '<' => $dtf->format_datetime($checkout_date) },
571 end_date => { '>' => $dtf->format_datetime($due_date) }
574 { order_by => { '-asc' => 'start_date' } }
578 my $loanable_items = {};
579 my $bookable_items = $biblio->bookable_items;
580 while ( my $item = $bookable_items->next ) {
581 $loanable_items->{ $item->itemnumber } = 1;
582 if ( my $checkout = $item->checkout ) {
583 $checkouts->{ $item->itemnumber } = dt_from_string( $checkout->date_due );
587 while ( my $booking = $bookings->next ) {
589 # Booking for this item
590 if ( defined( $booking->item_id )
591 && $booking->item_id == $self->itemnumber )
596 # Booking for another item
597 elsif ( defined( $booking->item_id ) ) {
598 # Due for another booking, remove from pool
599 delete $loanable_items->{ $booking->item_id };
604 # Booking for any item
606 # Can another item satisfy this booking?
615 $item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
617 Returns a boolean denoting whether the passed booking can be made without clashing.
619 Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
624 my ( $self, $params ) = @_;
626 my $start_date = dt_from_string( $params->{start_date} );
627 my $end_date = dt_from_string( $params->{end_date} );
628 my $booking_id = $params->{booking_id};
630 if ( my $checkout = $self->checkout ) {
631 return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
634 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
636 my $existing_bookings = $self->bookings(
640 $dtf->format_datetime($start_date),
641 $dtf->format_datetime($end_date)
646 $dtf->format_datetime($start_date),
647 $dtf->format_datetime($end_date)
651 start_date => { '<' => $dtf->format_datetime($start_date) },
652 end_date => { '>' => $dtf->format_datetime($end_date) }
659 ? $existing_bookings->search( { booking_id => { '!=' => $booking_id } } )->count
660 : $existing_bookings->count;
662 return $bookings_count ? 0 : 1;
665 =head3 request_transfer
667 my $transfer = $item->request_transfer(
671 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
675 Add a transfer request for this item to the given branch for the given reason.
677 An exception will be thrown if the BranchTransferLimits would prevent the requested
678 transfer, unless 'ignore_limits' is passed to override the limits.
680 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
681 The caller should catch such cases and retry the transfer request as appropriate passing
682 an appropriate override.
685 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
686 * replace - Used to replace the existing transfer request with your own.
690 sub request_transfer {
691 my ( $self, $params ) = @_;
693 # check for mandatory params
694 my @mandatory = ( 'to', 'reason' );
695 for my $param (@mandatory) {
696 unless ( defined( $params->{$param} ) ) {
697 Koha::Exceptions::MissingParameter->throw(
698 error => "The $param parameter is mandatory" );
702 Koha::Exceptions::Item::Transfer::Limit->throw()
703 unless ( $params->{ignore_limits}
704 || $self->can_be_transferred( { to => $params->{to} } ) );
706 my $request = $self->get_transfer;
707 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
708 if ( $request && !$params->{enqueue} && !$params->{replace} );
710 $request->cancel( { reason => $params->{reason}, force => 1 } )
711 if ( defined($request) && $params->{replace} );
713 my $transfer = Koha::Item::Transfer->new(
715 itemnumber => $self->itemnumber,
716 daterequested => dt_from_string,
717 frombranch => $self->holdingbranch,
718 tobranch => $params->{to}->branchcode,
719 reason => $params->{reason},
720 comments => $params->{comment}
729 my $transfer = $item->get_transfer;
731 Return the active transfer request or undef
733 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
734 whereby the most recently sent, but not received, transfer will be returned
735 if it exists, otherwise the oldest unsatisfied transfer will be returned.
737 This allows for transfers to queue, which is the case for stock rotation and
738 rotating collections where a manual transfer may need to take precedence but
739 we still expect the item to end up at a final location eventually.
746 my $transfer = $self->_result->current_branchtransfers->next;
747 return Koha::Item::Transfer->_new_from_dbic($transfer) if $transfer;
752 my $transfer = $item->get_transfers;
754 Return the list of outstanding transfers (i.e requested but not yet cancelled
757 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
758 whereby the most recently sent, but not received, transfer will be returned
759 first if it exists, otherwise requests are in oldest to newest request order.
761 This allows for transfers to queue, which is the case for stock rotation and
762 rotating collections where a manual transfer may need to take precedence but
763 we still expect the item to end up at a final location eventually.
770 my $transfer_rs = $self->_result->current_branchtransfers;
772 return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
775 =head3 last_returned_by
777 Gets and sets the last patron to return an item.
779 Accepts a patron's id (borrowernumber) and returns Koha::Patron objects
781 $item->last_returned_by( $borrowernumber );
783 my $patron = $item->last_returned_by();
787 sub last_returned_by {
788 my ( $self, $borrowernumber ) = @_;
789 if ( $borrowernumber ) {
790 $self->_result->update_or_create_related('last_returned_by',
791 { borrowernumber => $borrowernumber, itemnumber => $self->itemnumber } );
793 my $rs = $self->_result->last_returned_by;
795 return Koha::Patron->_new_from_dbic($rs->borrowernumber);
798 =head3 can_article_request
800 my $bool = $item->can_article_request( $borrower )
802 Returns true if item can be specifically requested
804 $borrower must be a Koha::Patron object
808 sub can_article_request {
809 my ( $self, $borrower ) = @_;
811 my $rule = $self->article_request_type($borrower);
813 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
817 =head3 hidden_in_opac
819 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
821 Returns true if item fields match the hidding criteria defined in $rules.
822 Returns false otherwise.
824 Takes HASHref that can have the following parameters:
826 $rules : { <field> => [ value_1, ... ], ... }
828 Note: $rules inherits its structure from the parsed YAML from reading
829 the I<OpacHiddenItems> system preference.
834 my ( $self, $params ) = @_;
836 my $rules = $params->{rules} // {};
839 if C4::Context->preference('hidelostitems') and
842 my $hidden_in_opac = 0;
844 foreach my $field ( keys %{$rules} ) {
846 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
852 return $hidden_in_opac;
855 =head3 can_be_transferred
857 $item->can_be_transferred({ to => $to_library, from => $from_library })
858 Checks if an item can be transferred to given library.
860 This feature is controlled by two system preferences:
861 UseBranchTransferLimits to enable / disable the feature
862 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
863 for setting the limitations
865 Takes HASHref that can have the following parameters:
866 MANDATORY PARAMETERS:
869 $from : Koha::Library # if not given, item holdingbranch
870 # will be used instead
872 Returns 1 if item can be transferred to $to_library, otherwise 0.
874 To find out whether at least one item of a Koha::Biblio can be transferred, please
875 see Koha::Biblio->can_be_transferred() instead of using this method for
876 multiple items of the same biblio.
880 sub can_be_transferred {
881 my ($self, $params) = @_;
883 my $to = $params->{to};
884 my $from = $params->{from};
886 $to = $to->branchcode;
887 $from = defined $from ? $from->branchcode : $self->holdingbranch;
889 return 1 if $from eq $to; # Transfer to current branch is allowed
890 return 1 unless C4::Context->preference('UseBranchTransferLimits');
892 my $limittype = C4::Context->preference('BranchTransferLimitsType');
893 return Koha::Item::Transfer::Limits->search({
896 $limittype => $limittype eq 'itemtype'
897 ? $self->effective_itemtype : $self->ccode
902 =head3 pickup_locations
904 my $pickup_locations = $item->pickup_locations({ patron => $patron })
906 Returns possible pickup locations for this item, according to patron's home library
907 and if item can be transferred to each pickup location.
909 Throws a I<Koha::Exceptions::MissingParameter> exception if the B<mandatory> parameter I<patron>
914 sub pickup_locations {
915 my ($self, $params) = @_;
917 Koha::Exceptions::MissingParameter->throw( parameter => 'patron' )
918 unless exists $params->{patron};
920 my $patron = $params->{patron};
922 my $circ_control_branch = Koha::Policy::Holds->holds_control_library( $self, $patron );
924 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
926 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
927 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
929 my $pickup_libraries = Koha::Libraries->search();
930 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
931 $pickup_libraries = $self->home_branch->get_hold_libraries;
932 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
933 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
934 $pickup_libraries = $plib->get_hold_libraries;
935 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
936 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
937 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
938 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
941 return $pickup_libraries->search(
946 order_by => ['branchname']
948 ) unless C4::Context->preference('UseBranchTransferLimits');
950 my $limittype = C4::Context->preference('BranchTransferLimitsType');
951 my ($ccode, $itype) = (undef, undef);
952 if( $limittype eq 'ccode' ){
953 $ccode = $self->ccode;
955 $itype = $self->itype;
957 my $limits = Koha::Item::Transfer::Limits->search(
959 fromBranch => $self->holdingbranch,
963 { columns => ['toBranch'] }
966 return $pickup_libraries->search(
968 pickup_location => 1,
970 '-not_in' => $limits->_resultset->as_query
974 order_by => ['branchname']
979 =head3 article_request_type
981 my $type = $item->article_request_type( $borrower )
983 returns 'yes', 'no', 'bib_only', or 'item_only'
985 $borrower must be a Koha::Patron object
989 sub article_request_type {
990 my ( $self, $borrower ) = @_;
992 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
994 $branch_control eq 'homebranch' ? $self->homebranch
995 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
997 my $borrowertype = $borrower->categorycode;
998 my $itemtype = $self->effective_itemtype();
999 my $rule = Koha::CirculationRules->get_effective_rule(
1001 rule_name => 'article_requests',
1002 categorycode => $borrowertype,
1003 itemtype => $itemtype,
1004 branchcode => $branchcode
1008 return q{} unless $rule;
1009 return $rule->rule_value || q{}
1012 =head3 current_holds
1018 my $attributes = { order_by => 'priority' };
1019 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1021 itemnumber => $self->itemnumber,
1024 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
1025 waitingdate => { '!=' => undef },
1028 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
1029 return Koha::Holds->_new_from_dbic($hold_rs);
1032 =head3 stockrotationitem
1034 my $sritem = Koha::Item->stockrotationitem;
1036 Returns the stock rotation item associated with the current item.
1040 sub stockrotationitem {
1042 my $rs = $self->_result->stockrotationitem;
1044 return Koha::StockRotationItem->_new_from_dbic( $rs );
1049 my $item = $item->add_to_rota($rota_id);
1051 Add this item to the rota identified by $ROTA_ID, which means associating it
1052 with the first stage of that rota. Should this item already be associated
1053 with a rota, then we will move it to the new rota.
1058 my ( $self, $rota_id ) = @_;
1059 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
1063 =head3 has_pending_hold
1065 my $is_pending_hold = $item->has_pending_hold();
1067 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
1071 sub has_pending_hold {
1073 return $self->_result->tmp_holdsqueue ? 1 : 0;
1076 =head3 has_pending_recall {
1078 my $has_pending_recall
1080 Return if whether has pending recall of not.
1084 sub has_pending_recall {
1087 # FIXME Must be moved to $self->recalls
1088 return Koha::Recalls->search(
1090 item_id => $self->itemnumber,
1091 status => 'waiting',
1096 =head3 as_marc_field
1098 my $field = $item->as_marc_field;
1100 This method returns a MARC::Field object representing the Koha::Item object
1101 with the current mappings configuration.
1108 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1110 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
1114 my $item_field = $tagslib->{$itemtag};
1116 my $more_subfields = $self->additional_attributes->to_hashref;
1117 foreach my $subfield (
1119 $a->{display_order} <=> $b->{display_order}
1120 || $a->{subfield} cmp $b->{subfield}
1121 } grep { ref($_) && %$_ } values %$item_field
1124 my $kohafield = $subfield->{kohafield};
1125 my $tagsubfield = $subfield->{tagsubfield};
1127 if ( defined $kohafield && $kohafield ne '' ) {
1128 next if $kohafield !~ m{^items\.}; # That would be weird!
1129 ( my $attribute = $kohafield ) =~ s|^items\.||;
1130 $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
1131 if defined $self->$attribute and $self->$attribute ne '';
1133 $value = $more_subfields->{$tagsubfield}
1136 next unless defined $value
1139 if ( $subfield->{repeatable} ) {
1140 my @values = split '\|', $value;
1141 push @subfields, ( $tagsubfield => $_ ) for @values;
1144 push @subfields, ( $tagsubfield => $value );
1149 return unless @subfields;
1151 return MARC::Field->new(
1152 "$itemtag", ' ', ' ', @subfields
1156 =head3 renewal_branchcode
1158 Returns the branchcode to be recorded in statistics renewal of the item
1162 sub renewal_branchcode {
1164 my ($self, $params ) = @_;
1166 my $interface = C4::Context->interface;
1168 if ( $interface eq 'opac' ){
1169 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1170 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
1171 $branchcode = 'OPACRenew';
1173 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
1174 $branchcode = $self->homebranch;
1176 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1177 $branchcode = $self->checkout->patron->branchcode;
1179 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1180 $branchcode = $self->checkout->branchcode;
1186 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1187 ? C4::Context->userenv->{branch} : $params->{branch};
1194 Return the cover images associated with this item.
1201 my $cover_image_rs = $self->_result->cover_images;
1202 return unless $cover_image_rs;
1203 return Koha::CoverImages->_new_from_dbic($cover_image_rs);
1206 =head3 columns_to_str
1208 my $values = $items->columns_to_str;
1210 Return a hashref with the string representation of the different attribute of the item.
1212 This is meant to be used for display purpose only.
1216 sub columns_to_str {
1218 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
1219 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
1220 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1222 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1225 for my $column ( @{$self->_columns}) {
1227 next if $column eq 'more_subfields_xml';
1229 my $value = $self->$column;
1230 # 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
1232 if ( not defined $value or $value eq "" ) {
1233 $values->{$column} = $value;
1238 exists $mss->{"items.$column"}
1239 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1242 $values->{$column} =
1244 ? $subfield->{authorised_value}
1245 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1246 $subfield->{tagsubfield}, $value, '', $tagslib )
1252 $self->more_subfields_xml
1253 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1258 my ( $field ) = $marc_more->fields;
1259 for my $sf ( $field->subfields ) {
1260 my $subfield_code = $sf->[0];
1261 my $value = $sf->[1];
1262 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1263 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1265 $subfield->{authorised_value}
1266 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1267 $subfield->{tagsubfield}, $value, '', $tagslib )
1270 push @{$more_values->{$subfield_code}}, $value;
1273 while ( my ( $k, $v ) = each %$more_values ) {
1274 $values->{$k} = join ' | ', @$v;
1281 =head3 additional_attributes
1283 my $attributes = $item->additional_attributes;
1284 $attributes->{k} = 'new k';
1285 $item->update({ more_subfields => $attributes->to_marcxml });
1287 Returns a Koha::Item::Attributes object that represents the non-mapped
1288 attributes for this item.
1292 sub additional_attributes {
1295 return Koha::Item::Attributes->new_from_marcxml(
1296 $self->more_subfields_xml,
1300 =head3 _set_found_trigger
1302 $self->_set_found_trigger
1304 Finds the most recent lost item charge for this item and refunds the patron
1305 appropriately, taking into account any payments or writeoffs already applied
1308 Internal function, not exported, called only by Koha::Item->store.
1312 sub _set_found_trigger {
1313 my ( $self, $pre_mod_item ) = @_;
1315 # Reverse any lost item charges if necessary.
1316 my $no_refund_after_days =
1317 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1318 if ($no_refund_after_days) {
1319 my $today = dt_from_string();
1320 my $lost_age_in_days =
1321 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1324 return $self unless $lost_age_in_days < $no_refund_after_days;
1327 my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
1330 return_branch => C4::Context->userenv
1331 ? C4::Context->userenv->{'branch'}
1335 my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
1337 if ( $lostreturn_policy ) {
1339 # refund charge made for lost book
1340 my $lost_charge = Koha::Account::Lines->search(
1342 itemnumber => $self->itemnumber,
1343 debit_type_code => 'LOST',
1344 status => [ undef, { '<>' => 'FOUND' } ]
1347 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1352 if ( $lost_charge ) {
1354 my $patron = $lost_charge->patron;
1357 my $account = $patron->account;
1359 # Credit outstanding amount
1360 my $credit_total = $lost_charge->amountoutstanding;
1364 $lost_charge->amount > $lost_charge->amountoutstanding &&
1365 $lostreturn_policy ne "refund_unpaid"
1367 # some amount has been cancelled. collect the offsets that are not writeoffs
1368 # this works because the only way to subtract from this kind of a debt is
1369 # using the UI buttons 'Pay' and 'Write off'
1371 # We don't credit any payments if return policy is
1374 # In that case only unpaid/outstanding amount
1375 # will be credited which settles the debt without
1376 # creating extra credits
1378 my $credit_offsets = $lost_charge->debit_offsets(
1380 'credit_id' => { '!=' => undef },
1381 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1383 { join => 'credit' }
1386 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1387 # credits are negative on the DB
1388 $credit_offsets->total * -1 :
1390 # Credit the outstanding amount, then add what has been
1391 # paid to create a net credit for this amount
1392 $credit_total += $total_to_refund;
1396 if ( $credit_total > 0 ) {
1398 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1399 $credit = $account->add_credit(
1401 amount => $credit_total,
1402 description => 'Item found ' . $self->itemnumber,
1403 type => 'LOST_FOUND',
1404 interface => C4::Context->interface,
1405 library_id => $branchcode,
1406 item_id => $self->itemnumber,
1407 issue_id => $lost_charge->issue_id
1411 $credit->apply( { debits => [$lost_charge] } );
1415 message => 'lost_refunded',
1416 payload => { credit_id => $credit->id }
1421 # Update the account status
1422 $lost_charge->status('FOUND');
1423 $lost_charge->store();
1425 # Reconcile balances if required
1426 if ( C4::Context->preference('AccountAutoReconcile') ) {
1427 $account->reconcile_balance;
1432 # possibly restore fine for lost book
1433 my $lost_overdue = Koha::Account::Lines->search(
1435 itemnumber => $self->itemnumber,
1436 debit_type_code => 'OVERDUE',
1440 order_by => { '-desc' => 'date' },
1444 if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
1446 my $patron = $lost_overdue->patron;
1448 my $account = $patron->account;
1450 # Update status of fine
1451 $lost_overdue->status('FOUND')->store();
1453 # Find related forgive credit
1454 my $refund = $lost_overdue->credits(
1456 credit_type_code => 'FORGIVEN',
1457 itemnumber => $self->itemnumber,
1458 status => [ { '!=' => 'VOID' }, undef ]
1460 { order_by => { '-desc' => 'date' }, rows => 1 }
1464 # Revert the forgive credit
1465 $refund->void({ interface => 'trigger' });
1469 message => 'lost_restored',
1470 payload => { refund_id => $refund->id }
1475 # Reconcile balances if required
1476 if ( C4::Context->preference('AccountAutoReconcile') ) {
1477 $account->reconcile_balance;
1481 } elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
1485 message => 'lost_charge',
1491 my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
1493 if ( $processingreturn_policy ) {
1495 # refund processing charge made for lost book
1496 my $processing_charge = Koha::Account::Lines->search(
1498 itemnumber => $self->itemnumber,
1499 debit_type_code => 'PROCESSING',
1500 status => [ undef, { '<>' => 'FOUND' } ]
1503 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1508 if ( $processing_charge ) {
1510 my $patron = $processing_charge->patron;
1513 my $account = $patron->account;
1515 # Credit outstanding amount
1516 my $credit_total = $processing_charge->amountoutstanding;
1520 $processing_charge->amount > $processing_charge->amountoutstanding &&
1521 $processingreturn_policy ne "refund_unpaid"
1523 # some amount has been cancelled. collect the offsets that are not writeoffs
1524 # this works because the only way to subtract from this kind of a debt is
1525 # using the UI buttons 'Pay' and 'Write off'
1527 # We don't credit any payments if return policy is
1530 # In that case only unpaid/outstanding amount
1531 # will be credited which settles the debt without
1532 # creating extra credits
1534 my $credit_offsets = $processing_charge->debit_offsets(
1536 'credit_id' => { '!=' => undef },
1537 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1539 { join => 'credit' }
1542 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1543 # credits are negative on the DB
1544 $credit_offsets->total * -1 :
1546 # Credit the outstanding amount, then add what has been
1547 # paid to create a net credit for this amount
1548 $credit_total += $total_to_refund;
1552 if ( $credit_total > 0 ) {
1554 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1555 $credit = $account->add_credit(
1557 amount => $credit_total,
1558 description => 'Item found ' . $self->itemnumber,
1559 type => 'PROCESSING_FOUND',
1560 interface => C4::Context->interface,
1561 library_id => $branchcode,
1562 item_id => $self->itemnumber,
1563 issue_id => $processing_charge->issue_id
1567 $credit->apply( { debits => [$processing_charge] } );
1571 message => 'processing_refunded',
1572 payload => { credit_id => $credit->id }
1577 # Update the account status
1578 $processing_charge->status('FOUND');
1579 $processing_charge->store();
1581 # Reconcile balances if required
1582 if ( C4::Context->preference('AccountAutoReconcile') ) {
1583 $account->reconcile_balance;
1592 =head3 public_read_list
1594 This method returns the list of publicly readable database fields for both API and UI output purposes
1598 sub public_read_list {
1600 'itemnumber', 'biblionumber', 'homebranch',
1601 'holdingbranch', 'location', 'collectioncode',
1602 'itemcallnumber', 'copynumber', 'enumchron',
1603 'barcode', 'dateaccessioned', 'itemnotes',
1604 'onloan', 'uri', 'itype',
1605 'notforloan', 'damaged', 'itemlost',
1606 'withdrawn', 'restricted'
1612 Overloaded to_api method to ensure item-level itypes is adhered to.
1617 my ($self, $params) = @_;
1619 my $response = $self->SUPER::to_api($params);
1622 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1624 my $itype_notforloan = $self->itemtype->notforloan;
1625 $overrides->{effective_not_for_loan_status} =
1626 ( defined $itype_notforloan && !$self->notforloan ) ? $itype_notforloan : $self->notforloan;
1628 return { %$response, %$overrides };
1631 =head3 to_api_mapping
1633 This method returns the mapping for representing a Koha::Item object
1638 sub to_api_mapping {
1640 itemnumber => 'item_id',
1641 biblionumber => 'biblio_id',
1642 biblioitemnumber => undef,
1643 barcode => 'external_id',
1644 dateaccessioned => 'acquisition_date',
1645 booksellerid => 'acquisition_source',
1646 homebranch => 'home_library_id',
1647 price => 'purchase_price',
1648 replacementprice => 'replacement_price',
1649 replacementpricedate => 'replacement_price_date',
1650 datelastborrowed => 'last_checkout_date',
1651 datelastseen => 'last_seen_date',
1653 notforloan => 'not_for_loan_status',
1654 damaged => 'damaged_status',
1655 damaged_on => 'damaged_date',
1656 itemlost => 'lost_status',
1657 itemlost_on => 'lost_date',
1658 withdrawn => 'withdrawn',
1659 withdrawn_on => 'withdrawn_date',
1660 itemcallnumber => 'callnumber',
1661 coded_location_qualifier => 'coded_location_qualifier',
1662 issues => 'checkouts_count',
1663 renewals => 'renewals_count',
1664 reserves => 'holds_count',
1665 restricted => 'restricted_status',
1666 itemnotes => 'public_notes',
1667 itemnotes_nonpublic => 'internal_notes',
1668 holdingbranch => 'holding_library_id',
1669 timestamp => 'timestamp',
1670 location => 'location',
1671 permanent_location => 'permanent_location',
1672 onloan => 'checked_out_date',
1673 cn_source => 'call_number_source',
1674 cn_sort => 'call_number_sort',
1675 ccode => 'collection_code',
1676 materials => 'materials_notes',
1678 itype => 'item_type_id',
1679 more_subfields_xml => 'extended_subfields',
1680 enumchron => 'serial_issue_number',
1681 copynumber => 'copy_number',
1682 stocknumber => 'inventory_number',
1683 new_status => 'new_status',
1684 deleted_on => undef,
1690 my $itemtype = $item->itemtype;
1692 Returns Koha object for effective itemtype
1699 return Koha::ItemTypes->find( $self->effective_itemtype );
1704 my $orders = $item->orders();
1706 Returns a Koha::Acquisition::Orders object
1713 my $orders = $self->_result->item_orders;
1714 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1717 =head3 tracked_links
1719 my $tracked_links = $item->tracked_links();
1721 Returns a Koha::TrackedLinks object
1728 my $tracked_links = $self->_result->linktrackers;
1729 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1732 =head3 move_to_biblio
1734 $item->move_to_biblio($to_biblio[, $params]);
1736 Move the item to another biblio and update any references in other tables.
1738 The final optional parameter, C<$params>, is expected to contain the
1739 'skip_record_index' key, which is relayed down to Koha::Item->store.
1740 There it prevents calling index_records, which takes most of the
1741 time in batch adds/deletes. The caller must take care of calling
1742 index_records separately.
1745 skip_record_index => 1|0
1747 Returns undef if the move failed or the biblionumber of the destination record otherwise
1751 sub move_to_biblio {
1752 my ( $self, $to_biblio, $params ) = @_;
1756 return if $self->biblionumber == $to_biblio->biblionumber;
1758 my $from_biblionumber = $self->biblionumber;
1759 my $to_biblionumber = $to_biblio->biblionumber;
1761 # Own biblionumber and biblioitemnumber
1763 biblionumber => $to_biblionumber,
1764 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1765 })->store({ skip_record_index => $params->{skip_record_index} });
1767 unless ($params->{skip_record_index}) {
1768 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1769 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1772 # Acquisition orders
1773 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1776 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1778 # hold_fill_target (there's no Koha object available yet)
1779 my $hold_fill_target = $self->_result->hold_fill_target;
1780 if ($hold_fill_target) {
1781 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1784 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1785 # and can't even fake one since the significant columns are nullable.
1786 my $storage = $self->_result->result_source->storage;
1789 my ($storage, $dbh, @cols) = @_;
1791 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1796 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1798 return $to_biblionumber;
1803 my $bundle_items = $item->bundle_items;
1805 Returns the items associated with this bundle
1812 my $rs = $self->_result->bundle_items;
1813 return Koha::Items->_new_from_dbic($rs);
1818 my $is_bundle = $item->is_bundle;
1820 Returns whether the item is a bundle or not
1826 return $self->bundle_items->count ? 1 : 0;
1831 my $bundle = $item->bundle_host;
1833 Returns the bundle item this item is attached to
1840 my $bundle_items_rs = $self->_result->item_bundles_item;
1841 return unless $bundle_items_rs;
1842 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1847 my $in_bundle = $item->in_bundle;
1849 Returns whether this item is currently in a bundle
1855 return $self->bundle_host ? 1 : 0;
1858 =head3 add_to_bundle
1860 my $link = $item->add_to_bundle($bundle_item);
1862 Adds the bundle_item passed to this item
1867 my ( $self, $bundle_item, $options ) = @_;
1871 Koha::Exceptions::Item::Bundle::IsBundle->throw()
1872 if ( $self->itemnumber eq $bundle_item->itemnumber
1873 || $bundle_item->is_bundle
1874 || $self->in_bundle );
1876 my $schema = Koha::Database->new->schema;
1878 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1884 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
1886 my $checkout = $bundle_item->checkout;
1888 unless ($options->{force_checkin}) {
1889 Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
1892 my $branchcode = C4::Context->userenv->{'branch'};
1893 my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
1895 Koha::Exceptions::Checkin::FailedCheckin->throw();
1899 my $holds = $bundle_item->current_holds;
1900 if ($holds->count) {
1901 unless ($options->{ignore_holds}) {
1902 Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
1906 $self->_result->add_to_item_bundles_hosts(
1907 { item => $bundle_item->itemnumber } );
1909 $bundle_item->notforloan($BundleNotLoanValue)->store();
1915 # 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
1916 if ( ref($_) eq 'DBIx::Class::Exception' ) {
1917 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1919 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1920 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1921 Koha::Exceptions::Object::FKConstraint->throw(
1922 error => 'Broken FK constraint',
1923 broken_fk => $+{column}
1928 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
1930 Koha::Exceptions::Object::DuplicateID->throw(
1931 error => 'Duplicate ID',
1932 duplicate_id => $+{key}
1935 elsif ( $_->{msg} =~
1936 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
1938 { # The optional \W in the regex might be a quote or backtick
1939 my $type = $+{type};
1940 my $value = $+{value};
1941 my $property = $+{property};
1942 $property =~ s/['`]//g;
1943 Koha::Exceptions::Object::BadValue->throw(
1946 property => $property =~ /(\w+\.\w+)$/
1949 , # results in table.column without quotes or backtics
1953 # Catch-all for foreign key breakages. It will help find other use cases
1962 =head3 remove_from_bundle
1964 Remove this item from any bundle it may have been attached to.
1968 sub remove_from_bundle {
1971 my $bundle_host = $self->bundle_host;
1973 return 0 unless $bundle_host; # Should not we raise an exception here?
1975 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $bundle_host->checkout;
1977 my $bundle_item_rs = $self->_result->item_bundles_item;
1978 if ( $bundle_item_rs ) {
1979 $bundle_item_rs->delete;
1980 $self->notforloan(0)->store();
1986 =head2 Internal methods
1988 =head3 _after_item_action_hooks
1990 Helper method that takes care of calling all plugin hooks
1994 sub _after_item_action_hooks {
1995 my ( $self, $params ) = @_;
1997 my $action = $params->{action};
1999 Koha::Plugins->call(
2000 'after_item_action',
2004 item_id => $self->itemnumber,
2011 my $recall = $item->recall;
2013 Return the relevant recall for this item
2019 my @recalls = Koha::Recalls->search(
2021 biblio_id => $self->biblionumber,
2024 { order_by => { -asc => 'created_date' } }
2027 my $item_level_recall;
2028 foreach my $recall (@recalls) {
2029 if ( $recall->item_level ) {
2030 $item_level_recall = 1;
2031 if ( $recall->item_id == $self->itemnumber ) {
2036 if ($item_level_recall) {
2038 # recall needs to be filled be a specific item only
2039 # no other item is relevant to return
2043 # no item-level recall to return, so return earliest biblio-level
2044 # FIXME: eventually this will be based on priority
2048 =head3 can_be_recalled
2050 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
2052 Does item-level checks and returns if items can be recalled by this borrower
2056 sub can_be_recalled {
2057 my ( $self, $params ) = @_;
2059 return 0 if !( C4::Context->preference('UseRecalls') );
2061 # check if this item is not for loan, withdrawn or lost
2062 return 0 if ( $self->notforloan != 0 );
2063 return 0 if ( $self->itemlost != 0 );
2064 return 0 if ( $self->withdrawn != 0 );
2066 # check if this item is not checked out - if not checked out, can't be recalled
2067 return 0 if ( !defined( $self->checkout ) );
2069 my $patron = $params->{patron};
2071 my $branchcode = C4::Context->userenv->{'branch'};
2073 $branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
2076 # Check the circulation rule for each relevant itemtype for this item
2077 my $rule = Koha::CirculationRules->get_effective_rules({
2078 branchcode => $branchcode,
2079 categorycode => $patron ? $patron->categorycode : undef,
2080 itemtype => $self->effective_itemtype,
2083 'recalls_per_record',
2088 # check recalls allowed has been set and is not zero
2089 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2092 # check borrower has not reached open recalls allowed limit
2093 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
2095 # check borrower has not reach open recalls allowed per record limit
2096 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
2098 # check if this patron has already recalled this item
2099 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
2101 # check if this patron has already checked out this item
2102 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2104 # check if this patron has already reserved this item
2105 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2108 # check item availability
2109 # items are unavailable for recall if they are lost, withdrawn or notforloan
2110 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
2112 # if there are no available items at all, no recall can be placed
2113 return 0 if ( scalar @items == 0 );
2115 my $checked_out_count = 0;
2117 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
2120 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
2121 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
2123 # can't recall if no items have been checked out
2124 return 0 if ( $checked_out_count == 0 );
2130 =head3 can_be_waiting_recall
2132 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
2134 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
2135 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
2139 sub can_be_waiting_recall {
2142 return 0 if !( C4::Context->preference('UseRecalls') );
2144 # check if this item is not for loan, withdrawn or lost
2145 return 0 if ( $self->notforloan != 0 );
2146 return 0 if ( $self->itemlost != 0 );
2147 return 0 if ( $self->withdrawn != 0 );
2149 my $branchcode = $self->holdingbranch;
2150 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
2151 $branchcode = C4::Context->userenv->{'branch'};
2153 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
2156 # Check the circulation rule for each relevant itemtype for this item
2157 my $most_relevant_recall = $self->check_recalls;
2158 my $rule = Koha::CirculationRules->get_effective_rules(
2160 branchcode => $branchcode,
2161 categorycode => $most_relevant_recall ? $most_relevant_recall->patron->categorycode : undef,
2162 itemtype => $self->effective_itemtype,
2163 rules => [ 'recalls_allowed', ],
2167 # check recalls allowed has been set and is not zero
2168 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2174 =head3 check_recalls
2176 my $recall = $item->check_recalls;
2178 Get the most relevant recall for this item.
2185 my @recalls = Koha::Recalls->search(
2186 { biblio_id => $self->biblionumber,
2187 item_id => [ $self->itemnumber, undef ]
2189 { order_by => { -asc => 'created_date' } }
2190 )->filter_by_current->as_list;
2193 # iterate through relevant recalls to find the best one.
2194 # if we come across a waiting recall, use this one.
2195 # 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.
2196 foreach my $r ( @recalls ) {
2197 if ( $r->waiting ) {
2202 unless ( defined $recall ) {
2203 $recall = $recalls[0];
2209 =head3 is_notforloan
2211 my $is_notforloan = $item->is_notforloan;
2213 Determine whether or not this item is "notforloan" based on
2214 the item's notforloan status or its item type
2220 my $is_notforloan = 0;
2222 if ( $self->notforloan ){
2226 my $itemtype = $self->itemtype;
2228 if ( $itemtype->notforloan ){
2234 return $is_notforloan;
2237 =head3 is_denied_renewal
2239 my $is_denied_renewal = $item->is_denied_renewal;
2241 Determine whether or not this item can be renewed based on the
2242 rules set in the ItemsDeniedRenewal system preference.
2246 sub is_denied_renewal {
2248 my $denyingrules = C4::Context->yaml_preference('ItemsDeniedRenewal');
2249 return 0 unless $denyingrules;
2250 foreach my $field (keys %$denyingrules) {
2251 # Silently ignore bad column names; TODO we should validate elsewhere
2252 next if !$self->_result->result_source->has_column($field);
2253 my $val = $self->$field;
2254 if( !defined $val) {
2255 if ( any { !defined $_ } @{$denyingrules->{$field}} ){
2258 } elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
2259 # If the results matches the values in the syspref
2260 # We return true if match found
2269 Returns a map of column name to string representations including the string,
2270 the mapping type and the mapping category where appropriate.
2272 Currently handles authorised value mappings, library, callnumber and itemtype
2275 Accepts a param hashref where the 'public' key denotes whether we want the public
2276 or staff client strings.
2281 my ( $self, $params ) = @_;
2282 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
2283 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
2284 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
2286 my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
2288 # Hardcoded known 'authorised_value' values mapped to API codes
2289 my $code_to_type = {
2290 branches => 'library',
2291 cn_source => 'call_number_source',
2292 itemtypes => 'item_type',
2295 # Handle not null and default values for integers and dates
2298 foreach my $col ( @{$self->_columns} ) {
2300 # By now, we are done with known columns, now check the framework for mappings
2301 my $field = $self->_result->result_source->name . '.' . $col;
2303 # Check there's an entry in the MARC subfield structure for the field
2304 if ( exists $mss->{$field}
2305 && scalar @{ $mss->{$field} } > 0
2306 && $mss->{$field}[0]->{authorised_value} )
2308 my $subfield = $mss->{$field}[0];
2309 my $code = $subfield->{authorised_value};
2311 my $str = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
2312 my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
2313 $strings->{$col} = {
2316 ( $type eq 'av' ? ( category => $code ) : () ),
2325 =head3 location_update_trigger
2327 $item->location_update_trigger( $action );
2329 Updates the item location based on I<$action>. It is done like this:
2333 =item For B<checkin>, location is updated following the I<UpdateItemLocationOnCheckin> preference.
2335 =item For B<checkout>, location is updated following the I<UpdateItemLocationOnCheckout> preference.
2339 FIXME: It should return I<$self>. See bug 35270.
2343 sub location_update_trigger {
2344 my ( $self, $action ) = @_;
2346 my ( $update_loc_rules, $messages );
2347 if ( $action eq 'checkin' ) {
2348 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckin');
2350 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckout');
2353 if ($update_loc_rules) {
2354 if ( defined $update_loc_rules->{_ALL_} ) {
2355 if ( $update_loc_rules->{_ALL_} eq '_PERM_' ) {
2356 $update_loc_rules->{_ALL_} = $self->permanent_location;
2358 if ( $update_loc_rules->{_ALL_} eq '_BLANK_' ) {
2359 $update_loc_rules->{_ALL_} = '';
2362 ( defined $self->location && $self->location ne $update_loc_rules->{_ALL_} )
2363 || ( !defined $self->location
2364 && $update_loc_rules->{_ALL_} ne "" )
2367 $messages->{'ItemLocationUpdated'} =
2368 { from => $self->location, to => $update_loc_rules->{_ALL_} };
2369 $self->location( $update_loc_rules->{_ALL_} )->store(
2372 skip_record_index => 1,
2373 skip_holds_queue => 1
2378 foreach my $key ( keys %$update_loc_rules ) {
2379 if ( $update_loc_rules->{$key} eq '_PERM_' ) {
2380 $update_loc_rules->{$key} = $self->permanent_location;
2381 } elsif ( $update_loc_rules->{$key} eq '_BLANK_' ) {
2382 $update_loc_rules->{$key} = '';
2386 defined $self->location
2387 && $self->location eq $key
2388 && $self->location ne $update_loc_rules->{$key}
2390 || ( $key eq '_BLANK_'
2391 && ( !defined $self->location || $self->location eq '' )
2392 && $update_loc_rules->{$key} ne '' )
2395 $messages->{'ItemLocationUpdated'} = {
2396 from => $self->location,
2397 to => $update_loc_rules->{$key}
2399 $self->location( $update_loc_rules->{$key} )->store(
2402 skip_record_index => 1,
2403 skip_holds_queue => 1
2424 Kyle M Hall <kyle@bywatersolutions.com>