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;
667 my $booking = $item->place_booking(
670 start_date => $datetime,
671 end_date => $datetime
675 Add a booking for this item for the dates passed.
677 Returns the Koha::Booking object or throws an exception if the item cannot be booked for the given dates.
682 my ( $self, $params ) = @_;
684 # check for mandatory params
685 my @mandatory = ( 'start_date', 'end_date', 'patron' );
686 for my $param (@mandatory) {
687 unless ( defined( $params->{$param} ) ) {
688 Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
691 my $patron = $params->{patron};
694 my $booking = Koha::Booking->new(
696 start_date => $params->{start_date},
697 end_date => $params->{end_date},
698 patron_id => $patron->borrowernumber,
699 biblio_id => $self->biblionumber,
700 item_id => $self->itemnumber,
706 =head3 request_transfer
708 my $transfer = $item->request_transfer(
712 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
716 Add a transfer request for this item to the given branch for the given reason.
718 An exception will be thrown if the BranchTransferLimits would prevent the requested
719 transfer, unless 'ignore_limits' is passed to override the limits.
721 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
722 The caller should catch such cases and retry the transfer request as appropriate passing
723 an appropriate override.
726 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
727 * replace - Used to replace the existing transfer request with your own.
731 sub request_transfer {
732 my ( $self, $params ) = @_;
734 # check for mandatory params
735 my @mandatory = ( 'to', 'reason' );
736 for my $param (@mandatory) {
737 unless ( defined( $params->{$param} ) ) {
738 Koha::Exceptions::MissingParameter->throw(
739 error => "The $param parameter is mandatory" );
743 Koha::Exceptions::Item::Transfer::Limit->throw()
744 unless ( $params->{ignore_limits}
745 || $self->can_be_transferred( { to => $params->{to} } ) );
747 my $request = $self->get_transfer;
748 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
749 if ( $request && !$params->{enqueue} && !$params->{replace} );
751 $request->cancel( { reason => $params->{reason}, force => 1 } )
752 if ( defined($request) && $params->{replace} );
754 my $transfer = Koha::Item::Transfer->new(
756 itemnumber => $self->itemnumber,
757 daterequested => dt_from_string,
758 frombranch => $self->holdingbranch,
759 tobranch => $params->{to}->branchcode,
760 reason => $params->{reason},
761 comments => $params->{comment}
770 my $transfer = $item->get_transfer;
772 Return the active transfer request or undef
774 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
775 whereby the most recently sent, but not received, transfer will be returned
776 if it exists, otherwise the oldest unsatisfied transfer will be returned.
778 This allows for transfers to queue, which is the case for stock rotation and
779 rotating collections where a manual transfer may need to take precedence but
780 we still expect the item to end up at a final location eventually.
787 my $transfer = $self->_result->current_branchtransfers->next;
788 return Koha::Item::Transfer->_new_from_dbic($transfer) if $transfer;
793 my $transfer = $item->get_transfers;
795 Return the list of outstanding transfers (i.e requested but not yet cancelled
798 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
799 whereby the most recently sent, but not received, transfer will be returned
800 first if it exists, otherwise requests are in oldest to newest request order.
802 This allows for transfers to queue, which is the case for stock rotation and
803 rotating collections where a manual transfer may need to take precedence but
804 we still expect the item to end up at a final location eventually.
811 my $transfer_rs = $self->_result->current_branchtransfers;
813 return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
816 =head3 last_returned_by
818 Gets and sets the last patron to return an item.
820 Accepts a patron's id (borrowernumber) and returns Koha::Patron objects
822 $item->last_returned_by( $borrowernumber );
824 my $patron = $item->last_returned_by();
828 sub last_returned_by {
829 my ( $self, $borrowernumber ) = @_;
830 if ( $borrowernumber ) {
831 $self->_result->update_or_create_related('last_returned_by',
832 { borrowernumber => $borrowernumber, itemnumber => $self->itemnumber } );
834 my $rs = $self->_result->last_returned_by;
836 return Koha::Patron->_new_from_dbic($rs->borrowernumber);
839 =head3 can_article_request
841 my $bool = $item->can_article_request( $borrower )
843 Returns true if item can be specifically requested
845 $borrower must be a Koha::Patron object
849 sub can_article_request {
850 my ( $self, $borrower ) = @_;
852 my $rule = $self->article_request_type($borrower);
854 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
858 =head3 hidden_in_opac
860 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
862 Returns true if item fields match the hidding criteria defined in $rules.
863 Returns false otherwise.
865 Takes HASHref that can have the following parameters:
867 $rules : { <field> => [ value_1, ... ], ... }
869 Note: $rules inherits its structure from the parsed YAML from reading
870 the I<OpacHiddenItems> system preference.
875 my ( $self, $params ) = @_;
877 my $rules = $params->{rules} // {};
880 if C4::Context->preference('hidelostitems') and
883 my $hidden_in_opac = 0;
885 foreach my $field ( keys %{$rules} ) {
887 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
893 return $hidden_in_opac;
896 =head3 can_be_transferred
898 $item->can_be_transferred({ to => $to_library, from => $from_library })
899 Checks if an item can be transferred to given library.
901 This feature is controlled by two system preferences:
902 UseBranchTransferLimits to enable / disable the feature
903 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
904 for setting the limitations
906 Takes HASHref that can have the following parameters:
907 MANDATORY PARAMETERS:
910 $from : Koha::Library # if not given, item holdingbranch
911 # will be used instead
913 Returns 1 if item can be transferred to $to_library, otherwise 0.
915 To find out whether at least one item of a Koha::Biblio can be transferred, please
916 see Koha::Biblio->can_be_transferred() instead of using this method for
917 multiple items of the same biblio.
921 sub can_be_transferred {
922 my ($self, $params) = @_;
924 my $to = $params->{to};
925 my $from = $params->{from};
927 $to = $to->branchcode;
928 $from = defined $from ? $from->branchcode : $self->holdingbranch;
930 return 1 if $from eq $to; # Transfer to current branch is allowed
931 return 1 unless C4::Context->preference('UseBranchTransferLimits');
933 my $limittype = C4::Context->preference('BranchTransferLimitsType');
934 return Koha::Item::Transfer::Limits->search({
937 $limittype => $limittype eq 'itemtype'
938 ? $self->effective_itemtype : $self->ccode
943 =head3 pickup_locations
945 my $pickup_locations = $item->pickup_locations({ patron => $patron })
947 Returns possible pickup locations for this item, according to patron's home library
948 and if item can be transferred to each pickup location.
950 Throws a I<Koha::Exceptions::MissingParameter> exception if the B<mandatory> parameter I<patron>
955 sub pickup_locations {
956 my ($self, $params) = @_;
958 Koha::Exceptions::MissingParameter->throw( parameter => 'patron' )
959 unless exists $params->{patron};
961 my $patron = $params->{patron};
963 my $circ_control_branch = Koha::Policy::Holds->holds_control_library( $self, $patron );
965 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
967 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
968 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
970 my $pickup_libraries = Koha::Libraries->search();
971 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
972 $pickup_libraries = $self->home_branch->get_hold_libraries;
973 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
974 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
975 $pickup_libraries = $plib->get_hold_libraries;
976 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
977 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
978 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
979 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
982 return $pickup_libraries->search(
987 order_by => ['branchname']
989 ) unless C4::Context->preference('UseBranchTransferLimits');
991 my $limittype = C4::Context->preference('BranchTransferLimitsType');
992 my ($ccode, $itype) = (undef, undef);
993 if( $limittype eq 'ccode' ){
994 $ccode = $self->ccode;
996 $itype = $self->itype;
998 my $limits = Koha::Item::Transfer::Limits->search(
1000 fromBranch => $self->holdingbranch,
1004 { columns => ['toBranch'] }
1007 return $pickup_libraries->search(
1009 pickup_location => 1,
1011 '-not_in' => $limits->_resultset->as_query
1015 order_by => ['branchname']
1020 =head3 article_request_type
1022 my $type = $item->article_request_type( $borrower )
1024 returns 'yes', 'no', 'bib_only', or 'item_only'
1026 $borrower must be a Koha::Patron object
1030 sub article_request_type {
1031 my ( $self, $borrower ) = @_;
1033 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
1035 $branch_control eq 'homebranch' ? $self->homebranch
1036 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
1038 my $borrowertype = $borrower->categorycode;
1039 my $itemtype = $self->effective_itemtype();
1040 my $rule = Koha::CirculationRules->get_effective_rule(
1042 rule_name => 'article_requests',
1043 categorycode => $borrowertype,
1044 itemtype => $itemtype,
1045 branchcode => $branchcode
1049 return q{} unless $rule;
1050 return $rule->rule_value || q{}
1053 =head3 current_holds
1059 my $attributes = { order_by => 'priority' };
1060 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1062 itemnumber => $self->itemnumber,
1065 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
1066 waitingdate => { '!=' => undef },
1069 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
1070 return Koha::Holds->_new_from_dbic($hold_rs);
1073 =head3 stockrotationitem
1075 my $sritem = Koha::Item->stockrotationitem;
1077 Returns the stock rotation item associated with the current item.
1081 sub stockrotationitem {
1083 my $rs = $self->_result->stockrotationitem;
1085 return Koha::StockRotationItem->_new_from_dbic( $rs );
1090 my $item = $item->add_to_rota($rota_id);
1092 Add this item to the rota identified by $ROTA_ID, which means associating it
1093 with the first stage of that rota. Should this item already be associated
1094 with a rota, then we will move it to the new rota.
1099 my ( $self, $rota_id ) = @_;
1100 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
1104 =head3 has_pending_hold
1106 my $is_pending_hold = $item->has_pending_hold();
1108 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
1112 sub has_pending_hold {
1114 return $self->_result->tmp_holdsqueue ? 1 : 0;
1117 =head3 has_pending_recall {
1119 my $has_pending_recall
1121 Return if whether has pending recall of not.
1125 sub has_pending_recall {
1128 # FIXME Must be moved to $self->recalls
1129 return Koha::Recalls->search(
1131 item_id => $self->itemnumber,
1132 status => 'waiting',
1137 =head3 as_marc_field
1139 my $field = $item->as_marc_field;
1141 This method returns a MARC::Field object representing the Koha::Item object
1142 with the current mappings configuration.
1149 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1151 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
1155 my $item_field = $tagslib->{$itemtag};
1157 my $more_subfields = $self->additional_attributes->to_hashref;
1158 foreach my $subfield (
1160 $a->{display_order} <=> $b->{display_order}
1161 || $a->{subfield} cmp $b->{subfield}
1162 } grep { ref($_) && %$_ } values %$item_field
1165 my $kohafield = $subfield->{kohafield};
1166 my $tagsubfield = $subfield->{tagsubfield};
1168 if ( defined $kohafield && $kohafield ne '' ) {
1169 next if $kohafield !~ m{^items\.}; # That would be weird!
1170 ( my $attribute = $kohafield ) =~ s|^items\.||;
1171 $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
1172 if defined $self->$attribute and $self->$attribute ne '';
1174 $value = $more_subfields->{$tagsubfield}
1177 next unless defined $value
1180 if ( $subfield->{repeatable} ) {
1181 my @values = split '\|', $value;
1182 push @subfields, ( $tagsubfield => $_ ) for @values;
1185 push @subfields, ( $tagsubfield => $value );
1190 return unless @subfields;
1192 return MARC::Field->new(
1193 "$itemtag", ' ', ' ', @subfields
1197 =head3 renewal_branchcode
1199 Returns the branchcode to be recorded in statistics renewal of the item
1203 sub renewal_branchcode {
1205 my ($self, $params ) = @_;
1207 my $interface = C4::Context->interface;
1209 if ( $interface eq 'opac' ){
1210 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1211 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
1212 $branchcode = 'OPACRenew';
1214 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
1215 $branchcode = $self->homebranch;
1217 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1218 $branchcode = $self->checkout->patron->branchcode;
1220 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1221 $branchcode = $self->checkout->branchcode;
1227 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1228 ? C4::Context->userenv->{branch} : $params->{branch};
1235 Return the cover images associated with this item.
1242 my $cover_image_rs = $self->_result->cover_images;
1243 return unless $cover_image_rs;
1244 return Koha::CoverImages->_new_from_dbic($cover_image_rs);
1247 =head3 columns_to_str
1249 my $values = $items->columns_to_str;
1251 Return a hashref with the string representation of the different attribute of the item.
1253 This is meant to be used for display purpose only.
1257 sub columns_to_str {
1259 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
1260 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
1261 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1263 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1266 for my $column ( @{$self->_columns}) {
1268 next if $column eq 'more_subfields_xml';
1270 my $value = $self->$column;
1271 # 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
1273 if ( not defined $value or $value eq "" ) {
1274 $values->{$column} = $value;
1279 exists $mss->{"items.$column"}
1280 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1283 $values->{$column} =
1285 ? $subfield->{authorised_value}
1286 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1287 $subfield->{tagsubfield}, $value, '', $tagslib )
1293 $self->more_subfields_xml
1294 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1299 my ( $field ) = $marc_more->fields;
1300 for my $sf ( $field->subfields ) {
1301 my $subfield_code = $sf->[0];
1302 my $value = $sf->[1];
1303 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1304 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1306 $subfield->{authorised_value}
1307 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1308 $subfield->{tagsubfield}, $value, '', $tagslib )
1311 push @{$more_values->{$subfield_code}}, $value;
1314 while ( my ( $k, $v ) = each %$more_values ) {
1315 $values->{$k} = join ' | ', @$v;
1322 =head3 additional_attributes
1324 my $attributes = $item->additional_attributes;
1325 $attributes->{k} = 'new k';
1326 $item->update({ more_subfields => $attributes->to_marcxml });
1328 Returns a Koha::Item::Attributes object that represents the non-mapped
1329 attributes for this item.
1333 sub additional_attributes {
1336 return Koha::Item::Attributes->new_from_marcxml(
1337 $self->more_subfields_xml,
1341 =head3 _set_found_trigger
1343 $self->_set_found_trigger
1345 Finds the most recent lost item charge for this item and refunds the patron
1346 appropriately, taking into account any payments or writeoffs already applied
1349 Internal function, not exported, called only by Koha::Item->store.
1353 sub _set_found_trigger {
1354 my ( $self, $pre_mod_item ) = @_;
1356 # Reverse any lost item charges if necessary.
1357 my $no_refund_after_days =
1358 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1359 if ($no_refund_after_days) {
1360 my $today = dt_from_string();
1361 my $lost_age_in_days =
1362 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1365 return $self unless $lost_age_in_days < $no_refund_after_days;
1368 my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
1371 return_branch => C4::Context->userenv
1372 ? C4::Context->userenv->{'branch'}
1376 my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
1378 if ( $lostreturn_policy ) {
1380 # refund charge made for lost book
1381 my $lost_charge = Koha::Account::Lines->search(
1383 itemnumber => $self->itemnumber,
1384 debit_type_code => 'LOST',
1385 status => [ undef, { '<>' => 'FOUND' } ]
1388 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1393 if ( $lost_charge ) {
1395 my $patron = $lost_charge->patron;
1398 my $account = $patron->account;
1400 # Credit outstanding amount
1401 my $credit_total = $lost_charge->amountoutstanding;
1405 $lost_charge->amount > $lost_charge->amountoutstanding &&
1406 $lostreturn_policy ne "refund_unpaid"
1408 # some amount has been cancelled. collect the offsets that are not writeoffs
1409 # this works because the only way to subtract from this kind of a debt is
1410 # using the UI buttons 'Pay' and 'Write off'
1412 # We don't credit any payments if return policy is
1415 # In that case only unpaid/outstanding amount
1416 # will be credited which settles the debt without
1417 # creating extra credits
1419 my $credit_offsets = $lost_charge->debit_offsets(
1421 'credit_id' => { '!=' => undef },
1422 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1424 { join => 'credit' }
1427 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1428 # credits are negative on the DB
1429 $credit_offsets->total * -1 :
1431 # Credit the outstanding amount, then add what has been
1432 # paid to create a net credit for this amount
1433 $credit_total += $total_to_refund;
1437 if ( $credit_total > 0 ) {
1439 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1440 $credit = $account->add_credit(
1442 amount => $credit_total,
1443 description => 'Item found ' . $self->itemnumber,
1444 type => 'LOST_FOUND',
1445 interface => C4::Context->interface,
1446 library_id => $branchcode,
1447 item_id => $self->itemnumber,
1448 issue_id => $lost_charge->issue_id
1452 $credit->apply( { debits => [$lost_charge] } );
1456 message => 'lost_refunded',
1457 payload => { credit_id => $credit->id }
1462 # Update the account status
1463 $lost_charge->status('FOUND');
1464 $lost_charge->store();
1466 # Reconcile balances if required
1467 if ( C4::Context->preference('AccountAutoReconcile') ) {
1468 $account->reconcile_balance;
1473 # possibly restore fine for lost book
1474 my $lost_overdue = Koha::Account::Lines->search(
1476 itemnumber => $self->itemnumber,
1477 debit_type_code => 'OVERDUE',
1481 order_by => { '-desc' => 'date' },
1485 if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
1487 my $patron = $lost_overdue->patron;
1489 my $account = $patron->account;
1491 # Update status of fine
1492 $lost_overdue->status('FOUND')->store();
1494 # Find related forgive credit
1495 my $refund = $lost_overdue->credits(
1497 credit_type_code => 'FORGIVEN',
1498 itemnumber => $self->itemnumber,
1499 status => [ { '!=' => 'VOID' }, undef ]
1501 { order_by => { '-desc' => 'date' }, rows => 1 }
1505 # Revert the forgive credit
1506 $refund->void({ interface => 'trigger' });
1510 message => 'lost_restored',
1511 payload => { refund_id => $refund->id }
1516 # Reconcile balances if required
1517 if ( C4::Context->preference('AccountAutoReconcile') ) {
1518 $account->reconcile_balance;
1522 } elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
1526 message => 'lost_charge',
1532 my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
1534 if ( $processingreturn_policy ) {
1536 # refund processing charge made for lost book
1537 my $processing_charge = Koha::Account::Lines->search(
1539 itemnumber => $self->itemnumber,
1540 debit_type_code => 'PROCESSING',
1541 status => [ undef, { '<>' => 'FOUND' } ]
1544 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1549 if ( $processing_charge ) {
1551 my $patron = $processing_charge->patron;
1554 my $account = $patron->account;
1556 # Credit outstanding amount
1557 my $credit_total = $processing_charge->amountoutstanding;
1561 $processing_charge->amount > $processing_charge->amountoutstanding &&
1562 $processingreturn_policy ne "refund_unpaid"
1564 # some amount has been cancelled. collect the offsets that are not writeoffs
1565 # this works because the only way to subtract from this kind of a debt is
1566 # using the UI buttons 'Pay' and 'Write off'
1568 # We don't credit any payments if return policy is
1571 # In that case only unpaid/outstanding amount
1572 # will be credited which settles the debt without
1573 # creating extra credits
1575 my $credit_offsets = $processing_charge->debit_offsets(
1577 'credit_id' => { '!=' => undef },
1578 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1580 { join => 'credit' }
1583 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1584 # credits are negative on the DB
1585 $credit_offsets->total * -1 :
1587 # Credit the outstanding amount, then add what has been
1588 # paid to create a net credit for this amount
1589 $credit_total += $total_to_refund;
1593 if ( $credit_total > 0 ) {
1595 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1596 $credit = $account->add_credit(
1598 amount => $credit_total,
1599 description => 'Item found ' . $self->itemnumber,
1600 type => 'PROCESSING_FOUND',
1601 interface => C4::Context->interface,
1602 library_id => $branchcode,
1603 item_id => $self->itemnumber,
1604 issue_id => $processing_charge->issue_id
1608 $credit->apply( { debits => [$processing_charge] } );
1612 message => 'processing_refunded',
1613 payload => { credit_id => $credit->id }
1618 # Update the account status
1619 $processing_charge->status('FOUND');
1620 $processing_charge->store();
1622 # Reconcile balances if required
1623 if ( C4::Context->preference('AccountAutoReconcile') ) {
1624 $account->reconcile_balance;
1633 =head3 public_read_list
1635 This method returns the list of publicly readable database fields for both API and UI output purposes
1639 sub public_read_list {
1641 'itemnumber', 'biblionumber', 'homebranch',
1642 'holdingbranch', 'location', 'collectioncode',
1643 'itemcallnumber', 'copynumber', 'enumchron',
1644 'barcode', 'dateaccessioned', 'itemnotes',
1645 'onloan', 'uri', 'itype',
1646 'notforloan', 'damaged', 'itemlost',
1647 'withdrawn', 'restricted'
1653 Overloaded to_api method to ensure item-level itypes is adhered to.
1658 my ($self, $params) = @_;
1660 my $response = $self->SUPER::to_api($params);
1663 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1665 my $itype_notforloan = $self->itemtype->notforloan;
1666 $overrides->{effective_not_for_loan_status} =
1667 ( defined $itype_notforloan && !$self->notforloan ) ? $itype_notforloan : $self->notforloan;
1669 return { %$response, %$overrides };
1672 =head3 to_api_mapping
1674 This method returns the mapping for representing a Koha::Item object
1679 sub to_api_mapping {
1681 itemnumber => 'item_id',
1682 biblionumber => 'biblio_id',
1683 biblioitemnumber => undef,
1684 barcode => 'external_id',
1685 dateaccessioned => 'acquisition_date',
1686 booksellerid => 'acquisition_source',
1687 homebranch => 'home_library_id',
1688 price => 'purchase_price',
1689 replacementprice => 'replacement_price',
1690 replacementpricedate => 'replacement_price_date',
1691 datelastborrowed => 'last_checkout_date',
1692 datelastseen => 'last_seen_date',
1694 notforloan => 'not_for_loan_status',
1695 damaged => 'damaged_status',
1696 damaged_on => 'damaged_date',
1697 itemlost => 'lost_status',
1698 itemlost_on => 'lost_date',
1699 withdrawn => 'withdrawn',
1700 withdrawn_on => 'withdrawn_date',
1701 itemcallnumber => 'callnumber',
1702 coded_location_qualifier => 'coded_location_qualifier',
1703 issues => 'checkouts_count',
1704 renewals => 'renewals_count',
1705 reserves => 'holds_count',
1706 restricted => 'restricted_status',
1707 itemnotes => 'public_notes',
1708 itemnotes_nonpublic => 'internal_notes',
1709 holdingbranch => 'holding_library_id',
1710 timestamp => 'timestamp',
1711 location => 'location',
1712 permanent_location => 'permanent_location',
1713 onloan => 'checked_out_date',
1714 cn_source => 'call_number_source',
1715 cn_sort => 'call_number_sort',
1716 ccode => 'collection_code',
1717 materials => 'materials_notes',
1719 itype => 'item_type_id',
1720 more_subfields_xml => 'extended_subfields',
1721 enumchron => 'serial_issue_number',
1722 copynumber => 'copy_number',
1723 stocknumber => 'inventory_number',
1724 new_status => 'new_status',
1725 deleted_on => undef,
1731 my $itemtype = $item->itemtype;
1733 Returns Koha object for effective itemtype
1740 return Koha::ItemTypes->find( $self->effective_itemtype );
1745 my $orders = $item->orders();
1747 Returns a Koha::Acquisition::Orders object
1754 my $orders = $self->_result->item_orders;
1755 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1758 =head3 tracked_links
1760 my $tracked_links = $item->tracked_links();
1762 Returns a Koha::TrackedLinks object
1769 my $tracked_links = $self->_result->linktrackers;
1770 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1773 =head3 move_to_biblio
1775 $item->move_to_biblio($to_biblio[, $params]);
1777 Move the item to another biblio and update any references in other tables.
1779 The final optional parameter, C<$params>, is expected to contain the
1780 'skip_record_index' key, which is relayed down to Koha::Item->store.
1781 There it prevents calling index_records, which takes most of the
1782 time in batch adds/deletes. The caller must take care of calling
1783 index_records separately.
1786 skip_record_index => 1|0
1788 Returns undef if the move failed or the biblionumber of the destination record otherwise
1792 sub move_to_biblio {
1793 my ( $self, $to_biblio, $params ) = @_;
1797 return if $self->biblionumber == $to_biblio->biblionumber;
1799 my $from_biblionumber = $self->biblionumber;
1800 my $to_biblionumber = $to_biblio->biblionumber;
1802 # Own biblionumber and biblioitemnumber
1804 biblionumber => $to_biblionumber,
1805 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1806 })->store({ skip_record_index => $params->{skip_record_index} });
1808 unless ($params->{skip_record_index}) {
1809 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1810 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1813 # Acquisition orders
1814 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1817 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1819 # hold_fill_target (there's no Koha object available yet)
1820 my $hold_fill_target = $self->_result->hold_fill_target;
1821 if ($hold_fill_target) {
1822 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1825 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1826 # and can't even fake one since the significant columns are nullable.
1827 my $storage = $self->_result->result_source->storage;
1830 my ($storage, $dbh, @cols) = @_;
1832 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1837 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1839 return $to_biblionumber;
1844 my $bundle_items = $item->bundle_items;
1846 Returns the items associated with this bundle
1853 my $rs = $self->_result->bundle_items;
1854 return Koha::Items->_new_from_dbic($rs);
1859 my $is_bundle = $item->is_bundle;
1861 Returns whether the item is a bundle or not
1867 return $self->bundle_items->count ? 1 : 0;
1872 my $bundle = $item->bundle_host;
1874 Returns the bundle item this item is attached to
1881 my $bundle_items_rs = $self->_result->item_bundles_item;
1882 return unless $bundle_items_rs;
1883 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1888 my $in_bundle = $item->in_bundle;
1890 Returns whether this item is currently in a bundle
1896 return $self->bundle_host ? 1 : 0;
1899 =head3 add_to_bundle
1901 my $link = $item->add_to_bundle($bundle_item);
1903 Adds the bundle_item passed to this item
1908 my ( $self, $bundle_item, $options ) = @_;
1912 Koha::Exceptions::Item::Bundle::IsBundle->throw()
1913 if ( $self->itemnumber eq $bundle_item->itemnumber
1914 || $bundle_item->is_bundle
1915 || $self->in_bundle );
1917 my $schema = Koha::Database->new->schema;
1919 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1925 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
1927 my $checkout = $bundle_item->checkout;
1929 unless ($options->{force_checkin}) {
1930 Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
1933 my $branchcode = C4::Context->userenv->{'branch'};
1934 my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
1936 Koha::Exceptions::Checkin::FailedCheckin->throw();
1940 my $holds = $bundle_item->current_holds;
1941 if ($holds->count) {
1942 unless ($options->{ignore_holds}) {
1943 Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
1947 $self->_result->add_to_item_bundles_hosts(
1948 { item => $bundle_item->itemnumber } );
1950 $bundle_item->notforloan($BundleNotLoanValue)->store();
1956 # 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
1957 if ( ref($_) eq 'DBIx::Class::Exception' ) {
1958 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1960 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1961 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1962 Koha::Exceptions::Object::FKConstraint->throw(
1963 error => 'Broken FK constraint',
1964 broken_fk => $+{column}
1969 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
1971 Koha::Exceptions::Object::DuplicateID->throw(
1972 error => 'Duplicate ID',
1973 duplicate_id => $+{key}
1976 elsif ( $_->{msg} =~
1977 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
1979 { # The optional \W in the regex might be a quote or backtick
1980 my $type = $+{type};
1981 my $value = $+{value};
1982 my $property = $+{property};
1983 $property =~ s/['`]//g;
1984 Koha::Exceptions::Object::BadValue->throw(
1987 property => $property =~ /(\w+\.\w+)$/
1990 , # results in table.column without quotes or backtics
1994 # Catch-all for foreign key breakages. It will help find other use cases
2003 =head3 remove_from_bundle
2005 Remove this item from any bundle it may have been attached to.
2009 sub remove_from_bundle {
2012 my $bundle_host = $self->bundle_host;
2014 return 0 unless $bundle_host; # Should not we raise an exception here?
2016 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $bundle_host->checkout;
2018 my $bundle_item_rs = $self->_result->item_bundles_item;
2019 if ( $bundle_item_rs ) {
2020 $bundle_item_rs->delete;
2021 $self->notforloan(0)->store();
2027 =head2 Internal methods
2029 =head3 _after_item_action_hooks
2031 Helper method that takes care of calling all plugin hooks
2035 sub _after_item_action_hooks {
2036 my ( $self, $params ) = @_;
2038 my $action = $params->{action};
2040 Koha::Plugins->call(
2041 'after_item_action',
2045 item_id => $self->itemnumber,
2052 my $recall = $item->recall;
2054 Return the relevant recall for this item
2060 my @recalls = Koha::Recalls->search(
2062 biblio_id => $self->biblionumber,
2065 { order_by => { -asc => 'created_date' } }
2068 my $item_level_recall;
2069 foreach my $recall (@recalls) {
2070 if ( $recall->item_level ) {
2071 $item_level_recall = 1;
2072 if ( $recall->item_id == $self->itemnumber ) {
2077 if ($item_level_recall) {
2079 # recall needs to be filled be a specific item only
2080 # no other item is relevant to return
2084 # no item-level recall to return, so return earliest biblio-level
2085 # FIXME: eventually this will be based on priority
2089 =head3 can_be_recalled
2091 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
2093 Does item-level checks and returns if items can be recalled by this borrower
2097 sub can_be_recalled {
2098 my ( $self, $params ) = @_;
2100 return 0 if !( C4::Context->preference('UseRecalls') );
2102 # check if this item is not for loan, withdrawn or lost
2103 return 0 if ( $self->notforloan != 0 );
2104 return 0 if ( $self->itemlost != 0 );
2105 return 0 if ( $self->withdrawn != 0 );
2107 # check if this item is not checked out - if not checked out, can't be recalled
2108 return 0 if ( !defined( $self->checkout ) );
2110 my $patron = $params->{patron};
2112 my $branchcode = C4::Context->userenv->{'branch'};
2114 $branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
2117 # Check the circulation rule for each relevant itemtype for this item
2118 my $rule = Koha::CirculationRules->get_effective_rules({
2119 branchcode => $branchcode,
2120 categorycode => $patron ? $patron->categorycode : undef,
2121 itemtype => $self->effective_itemtype,
2124 'recalls_per_record',
2129 # check recalls allowed has been set and is not zero
2130 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2133 # check borrower has not reached open recalls allowed limit
2134 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
2136 # check borrower has not reach open recalls allowed per record limit
2137 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
2139 # check if this patron has already recalled this item
2140 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
2142 # check if this patron has already checked out this item
2143 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2145 # check if this patron has already reserved this item
2146 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2149 # check item availability
2150 # items are unavailable for recall if they are lost, withdrawn or notforloan
2151 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
2153 # if there are no available items at all, no recall can be placed
2154 return 0 if ( scalar @items == 0 );
2156 my $checked_out_count = 0;
2158 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
2161 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
2162 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
2164 # can't recall if no items have been checked out
2165 return 0 if ( $checked_out_count == 0 );
2171 =head3 can_be_waiting_recall
2173 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
2175 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
2176 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
2180 sub can_be_waiting_recall {
2183 return 0 if !( C4::Context->preference('UseRecalls') );
2185 # check if this item is not for loan, withdrawn or lost
2186 return 0 if ( $self->notforloan != 0 );
2187 return 0 if ( $self->itemlost != 0 );
2188 return 0 if ( $self->withdrawn != 0 );
2190 my $branchcode = $self->holdingbranch;
2191 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
2192 $branchcode = C4::Context->userenv->{'branch'};
2194 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
2197 # Check the circulation rule for each relevant itemtype for this item
2198 my $most_relevant_recall = $self->check_recalls;
2199 my $rule = Koha::CirculationRules->get_effective_rules(
2201 branchcode => $branchcode,
2202 categorycode => $most_relevant_recall ? $most_relevant_recall->patron->categorycode : undef,
2203 itemtype => $self->effective_itemtype,
2204 rules => [ 'recalls_allowed', ],
2208 # check recalls allowed has been set and is not zero
2209 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2215 =head3 check_recalls
2217 my $recall = $item->check_recalls;
2219 Get the most relevant recall for this item.
2226 my @recalls = Koha::Recalls->search(
2227 { biblio_id => $self->biblionumber,
2228 item_id => [ $self->itemnumber, undef ]
2230 { order_by => { -asc => 'created_date' } }
2231 )->filter_by_current->as_list;
2234 # iterate through relevant recalls to find the best one.
2235 # if we come across a waiting recall, use this one.
2236 # 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.
2237 foreach my $r ( @recalls ) {
2238 if ( $r->waiting ) {
2243 unless ( defined $recall ) {
2244 $recall = $recalls[0];
2250 =head3 is_notforloan
2252 my $is_notforloan = $item->is_notforloan;
2254 Determine whether or not this item is "notforloan" based on
2255 the item's notforloan status or its item type
2261 my $is_notforloan = 0;
2263 if ( $self->notforloan ){
2267 my $itemtype = $self->itemtype;
2269 if ( $itemtype->notforloan ){
2275 return $is_notforloan;
2278 =head3 is_denied_renewal
2280 my $is_denied_renewal = $item->is_denied_renewal;
2282 Determine whether or not this item can be renewed based on the
2283 rules set in the ItemsDeniedRenewal system preference.
2287 sub is_denied_renewal {
2289 my $denyingrules = C4::Context->yaml_preference('ItemsDeniedRenewal');
2290 return 0 unless $denyingrules;
2291 foreach my $field (keys %$denyingrules) {
2292 # Silently ignore bad column names; TODO we should validate elsewhere
2293 next if !$self->_result->result_source->has_column($field);
2294 my $val = $self->$field;
2295 if( !defined $val) {
2296 if ( any { !defined $_ } @{$denyingrules->{$field}} ){
2299 } elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
2300 # If the results matches the values in the syspref
2301 # We return true if match found
2310 Returns a map of column name to string representations including the string,
2311 the mapping type and the mapping category where appropriate.
2313 Currently handles authorised value mappings, library, callnumber and itemtype
2316 Accepts a param hashref where the 'public' key denotes whether we want the public
2317 or staff client strings.
2322 my ( $self, $params ) = @_;
2323 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
2324 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
2325 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
2327 my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
2329 # Hardcoded known 'authorised_value' values mapped to API codes
2330 my $code_to_type = {
2331 branches => 'library',
2332 cn_source => 'call_number_source',
2333 itemtypes => 'item_type',
2336 # Handle not null and default values for integers and dates
2339 foreach my $col ( @{$self->_columns} ) {
2341 # By now, we are done with known columns, now check the framework for mappings
2342 my $field = $self->_result->result_source->name . '.' . $col;
2344 # Check there's an entry in the MARC subfield structure for the field
2345 if ( exists $mss->{$field}
2346 && scalar @{ $mss->{$field} } > 0
2347 && $mss->{$field}[0]->{authorised_value} )
2349 my $subfield = $mss->{$field}[0];
2350 my $code = $subfield->{authorised_value};
2352 my $str = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
2353 my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
2354 $strings->{$col} = {
2357 ( $type eq 'av' ? ( category => $code ) : () ),
2366 =head3 location_update_trigger
2368 $item->location_update_trigger( $action );
2370 Updates the item location based on I<$action>. It is done like this:
2374 =item For B<checkin>, location is updated following the I<UpdateItemLocationOnCheckin> preference.
2376 =item For B<checkout>, location is updated following the I<UpdateItemLocationOnCheckout> preference.
2380 FIXME: It should return I<$self>. See bug 35270.
2384 sub location_update_trigger {
2385 my ( $self, $action ) = @_;
2387 my ( $update_loc_rules, $messages );
2388 if ( $action eq 'checkin' ) {
2389 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckin');
2391 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckout');
2394 if ($update_loc_rules) {
2395 if ( defined $update_loc_rules->{_ALL_} ) {
2396 if ( $update_loc_rules->{_ALL_} eq '_PERM_' ) {
2397 $update_loc_rules->{_ALL_} = $self->permanent_location;
2399 if ( $update_loc_rules->{_ALL_} eq '_BLANK_' ) {
2400 $update_loc_rules->{_ALL_} = '';
2403 ( defined $self->location && $self->location ne $update_loc_rules->{_ALL_} )
2404 || ( !defined $self->location
2405 && $update_loc_rules->{_ALL_} ne "" )
2408 $messages->{'ItemLocationUpdated'} =
2409 { from => $self->location, to => $update_loc_rules->{_ALL_} };
2410 $self->location( $update_loc_rules->{_ALL_} )->store(
2413 skip_record_index => 1,
2414 skip_holds_queue => 1
2419 foreach my $key ( keys %$update_loc_rules ) {
2420 if ( $update_loc_rules->{$key} eq '_PERM_' ) {
2421 $update_loc_rules->{$key} = $self->permanent_location;
2422 } elsif ( $update_loc_rules->{$key} eq '_BLANK_' ) {
2423 $update_loc_rules->{$key} = '';
2427 defined $self->location
2428 && $self->location eq $key
2429 && $self->location ne $update_loc_rules->{$key}
2431 || ( $key eq '_BLANK_'
2432 && ( !defined $self->location || $self->location eq '' )
2433 && $update_loc_rules->{$key} ne '' )
2436 $messages->{'ItemLocationUpdated'} = {
2437 from => $self->location,
2438 to => $update_loc_rules->{$key}
2440 $self->location( $update_loc_rules->{$key} )->store(
2443 skip_record_index => 1,
2444 skip_holds_queue => 1
2465 Kyle M Hall <kyle@bywatersolutions.com>