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 Find the first booking that would conflict with the passed checkout dates
536 my ( $self, $params ) = @_;
538 my $checkout_date = $params->{checkout_date};
539 my $due_date = $params->{due_date};
540 my $biblio = $self->biblio;
542 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
543 my $bookings = $biblio->bookings(
545 # Checkout starts during booked period
548 $dtf->format_datetime($checkout_date),
549 $dtf->format_datetime($due_date)
553 # Checkout is due during booked period
556 $dtf->format_datetime($checkout_date),
557 $dtf->format_datetime($due_date)
561 # Checkout contains booked period
563 start_date => { '<' => $dtf->format_datetime($checkout_date) },
564 end_date => { '>' => $dtf->format_datetime($due_date) }
568 order_by => { '-asc' => 'start_date' }
573 my $loanable_items = {};
574 my $bookable_items = $biblio->bookable_items;
575 while ( my $item = $bookable_items->next ) {
576 $loanable_items->{ $item->itemnumber } = 1;
577 if ( my $checkout = $item->checkout ) {
578 $checkouts->{ $item->itemnumber } =
579 dt_from_string( $checkout->date_due );
583 while ( my $booking = $bookings->next ) {
585 # Booking for this item
586 if ( defined( $booking->item_id )
587 && $booking->item_id == $self->itemnumber )
592 # Booking for another item
593 elsif ( defined( $booking->item_id ) ) {
594 # Due for another booking, remove from pool
595 delete $loanable_items->{ $booking->item_id };
600 # Booking for any item
602 # Can another item satisfy this booking?
611 $item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
613 Returns a boolean denoting whether the passed booking can be made without clashing.
615 Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
620 my ($self, $params) = @_;
622 my $start_date = dt_from_string( $params->{start_date} );
623 my $end_date = dt_from_string( $params->{end_date} );
624 my $booking_id = $params->{booking_id};
626 if ( my $checkout = $self->checkout ) {
627 return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
630 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
631 my $existing_bookings = $self->bookings(
635 $dtf->format_datetime($start_date),
636 $dtf->format_datetime($end_date)
641 $dtf->format_datetime($start_date),
642 $dtf->format_datetime($end_date)
646 start_date => { '<' => $dtf->format_datetime($start_date) },
647 end_date => { '>' => $dtf->format_datetime($end_date) }
654 ? $existing_bookings->search( { booking_id => { '!=' => $booking_id } } )
656 : $existing_bookings->count;
658 return $bookings_count ? 0 : 1;
663 my $booking = $item->place_booking(
666 start_date => $datetime,
667 end_date => $datetime
671 Add a booking for this item for the dates passed.
673 Returns the Koha::Booking object or throws an exception if the item cannot be booked for the given dates.
678 my ( $self, $params ) = @_;
680 # check for mandatory params
681 my @mandatory = ( 'start_date', 'end_date', 'patron' );
682 for my $param (@mandatory) {
683 unless ( defined( $params->{$param} ) ) {
684 Koha::Exceptions::MissingParameter->throw(
685 error => "The $param parameter is mandatory" );
688 my $patron = $params->{patron};
691 my $booking = Koha::Booking->new(
693 start_date => $params->{start_date},
694 end_date => $params->{end_date},
695 patron_id => $patron->borrowernumber,
696 biblio_id => $self->biblionumber,
697 item_id => $self->itemnumber,
703 =head3 request_transfer
705 my $transfer = $item->request_transfer(
709 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
713 Add a transfer request for this item to the given branch for the given reason.
715 An exception will be thrown if the BranchTransferLimits would prevent the requested
716 transfer, unless 'ignore_limits' is passed to override the limits.
718 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
719 The caller should catch such cases and retry the transfer request as appropriate passing
720 an appropriate override.
723 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
724 * replace - Used to replace the existing transfer request with your own.
728 sub request_transfer {
729 my ( $self, $params ) = @_;
731 # check for mandatory params
732 my @mandatory = ( 'to', 'reason' );
733 for my $param (@mandatory) {
734 unless ( defined( $params->{$param} ) ) {
735 Koha::Exceptions::MissingParameter->throw(
736 error => "The $param parameter is mandatory" );
740 Koha::Exceptions::Item::Transfer::Limit->throw()
741 unless ( $params->{ignore_limits}
742 || $self->can_be_transferred( { to => $params->{to} } ) );
744 my $request = $self->get_transfer;
745 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
746 if ( $request && !$params->{enqueue} && !$params->{replace} );
748 $request->cancel( { reason => $params->{reason}, force => 1 } )
749 if ( defined($request) && $params->{replace} );
751 my $transfer = Koha::Item::Transfer->new(
753 itemnumber => $self->itemnumber,
754 daterequested => dt_from_string,
755 frombranch => $self->holdingbranch,
756 tobranch => $params->{to}->branchcode,
757 reason => $params->{reason},
758 comments => $params->{comment}
767 my $transfer = $item->get_transfer;
769 Return the active transfer request or undef
771 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
772 whereby the most recently sent, but not received, transfer will be returned
773 if it exists, otherwise the oldest unsatisfied transfer will be returned.
775 This allows for transfers to queue, which is the case for stock rotation and
776 rotating collections where a manual transfer may need to take precedence but
777 we still expect the item to end up at a final location eventually.
784 my $transfer = $self->_result->current_branchtransfers->next;
785 return Koha::Item::Transfer->_new_from_dbic($transfer) if $transfer;
790 my $transfer = $item->get_transfers;
792 Return the list of outstanding transfers (i.e requested but not yet cancelled
795 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
796 whereby the most recently sent, but not received, transfer will be returned
797 first if it exists, otherwise requests are in oldest to newest request order.
799 This allows for transfers to queue, which is the case for stock rotation and
800 rotating collections where a manual transfer may need to take precedence but
801 we still expect the item to end up at a final location eventually.
808 my $transfer_rs = $self->_result->current_branchtransfers;
810 return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
813 =head3 last_returned_by
815 Gets and sets the last patron to return an item.
817 Accepts a patron's id (borrowernumber) and returns Koha::Patron objects
819 $item->last_returned_by( $borrowernumber );
821 my $patron = $item->last_returned_by();
825 sub last_returned_by {
826 my ( $self, $borrowernumber ) = @_;
827 if ( $borrowernumber ) {
828 $self->_result->update_or_create_related('last_returned_by',
829 { borrowernumber => $borrowernumber, itemnumber => $self->itemnumber } );
831 my $rs = $self->_result->last_returned_by;
833 return Koha::Patron->_new_from_dbic($rs->borrowernumber);
836 =head3 can_article_request
838 my $bool = $item->can_article_request( $borrower )
840 Returns true if item can be specifically requested
842 $borrower must be a Koha::Patron object
846 sub can_article_request {
847 my ( $self, $borrower ) = @_;
849 my $rule = $self->article_request_type($borrower);
851 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
855 =head3 hidden_in_opac
857 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
859 Returns true if item fields match the hidding criteria defined in $rules.
860 Returns false otherwise.
862 Takes HASHref that can have the following parameters:
864 $rules : { <field> => [ value_1, ... ], ... }
866 Note: $rules inherits its structure from the parsed YAML from reading
867 the I<OpacHiddenItems> system preference.
872 my ( $self, $params ) = @_;
874 my $rules = $params->{rules} // {};
877 if C4::Context->preference('hidelostitems') and
880 my $hidden_in_opac = 0;
882 foreach my $field ( keys %{$rules} ) {
884 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
890 return $hidden_in_opac;
893 =head3 can_be_transferred
895 $item->can_be_transferred({ to => $to_library, from => $from_library })
896 Checks if an item can be transferred to given library.
898 This feature is controlled by two system preferences:
899 UseBranchTransferLimits to enable / disable the feature
900 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
901 for setting the limitations
903 Takes HASHref that can have the following parameters:
904 MANDATORY PARAMETERS:
907 $from : Koha::Library # if not given, item holdingbranch
908 # will be used instead
910 Returns 1 if item can be transferred to $to_library, otherwise 0.
912 To find out whether at least one item of a Koha::Biblio can be transferred, please
913 see Koha::Biblio->can_be_transferred() instead of using this method for
914 multiple items of the same biblio.
918 sub can_be_transferred {
919 my ($self, $params) = @_;
921 my $to = $params->{to};
922 my $from = $params->{from};
924 $to = $to->branchcode;
925 $from = defined $from ? $from->branchcode : $self->holdingbranch;
927 return 1 if $from eq $to; # Transfer to current branch is allowed
928 return 1 unless C4::Context->preference('UseBranchTransferLimits');
930 my $limittype = C4::Context->preference('BranchTransferLimitsType');
931 return Koha::Item::Transfer::Limits->search({
934 $limittype => $limittype eq 'itemtype'
935 ? $self->effective_itemtype : $self->ccode
940 =head3 pickup_locations
942 my $pickup_locations = $item->pickup_locations({ patron => $patron })
944 Returns possible pickup locations for this item, according to patron's home library
945 and if item can be transferred to each pickup location.
947 Throws a I<Koha::Exceptions::MissingParameter> exception if the B<mandatory> parameter I<patron>
952 sub pickup_locations {
953 my ($self, $params) = @_;
955 Koha::Exceptions::MissingParameter->throw( parameter => 'patron' )
956 unless exists $params->{patron};
958 my $patron = $params->{patron};
960 my $circ_control_branch = Koha::Policy::Holds->holds_control_library( $self, $patron );
962 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
964 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
965 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
967 my $pickup_libraries = Koha::Libraries->search();
968 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
969 $pickup_libraries = $self->home_branch->get_hold_libraries;
970 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
971 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
972 $pickup_libraries = $plib->get_hold_libraries;
973 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
974 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
975 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
976 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
979 return $pickup_libraries->search(
984 order_by => ['branchname']
986 ) unless C4::Context->preference('UseBranchTransferLimits');
988 my $limittype = C4::Context->preference('BranchTransferLimitsType');
989 my ($ccode, $itype) = (undef, undef);
990 if( $limittype eq 'ccode' ){
991 $ccode = $self->ccode;
993 $itype = $self->itype;
995 my $limits = Koha::Item::Transfer::Limits->search(
997 fromBranch => $self->holdingbranch,
1001 { columns => ['toBranch'] }
1004 return $pickup_libraries->search(
1006 pickup_location => 1,
1008 '-not_in' => $limits->_resultset->as_query
1012 order_by => ['branchname']
1017 =head3 article_request_type
1019 my $type = $item->article_request_type( $borrower )
1021 returns 'yes', 'no', 'bib_only', or 'item_only'
1023 $borrower must be a Koha::Patron object
1027 sub article_request_type {
1028 my ( $self, $borrower ) = @_;
1030 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
1032 $branch_control eq 'homebranch' ? $self->homebranch
1033 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
1035 my $borrowertype = $borrower->categorycode;
1036 my $itemtype = $self->effective_itemtype();
1037 my $rule = Koha::CirculationRules->get_effective_rule(
1039 rule_name => 'article_requests',
1040 categorycode => $borrowertype,
1041 itemtype => $itemtype,
1042 branchcode => $branchcode
1046 return q{} unless $rule;
1047 return $rule->rule_value || q{}
1050 =head3 current_holds
1056 my $attributes = { order_by => 'priority' };
1057 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1059 itemnumber => $self->itemnumber,
1062 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
1063 waitingdate => { '!=' => undef },
1066 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
1067 return Koha::Holds->_new_from_dbic($hold_rs);
1070 =head3 stockrotationitem
1072 my $sritem = Koha::Item->stockrotationitem;
1074 Returns the stock rotation item associated with the current item.
1078 sub stockrotationitem {
1080 my $rs = $self->_result->stockrotationitem;
1082 return Koha::StockRotationItem->_new_from_dbic( $rs );
1087 my $item = $item->add_to_rota($rota_id);
1089 Add this item to the rota identified by $ROTA_ID, which means associating it
1090 with the first stage of that rota. Should this item already be associated
1091 with a rota, then we will move it to the new rota.
1096 my ( $self, $rota_id ) = @_;
1097 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
1101 =head3 has_pending_hold
1103 my $is_pending_hold = $item->has_pending_hold();
1105 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
1109 sub has_pending_hold {
1111 return $self->_result->tmp_holdsqueue ? 1 : 0;
1114 =head3 has_pending_recall {
1116 my $has_pending_recall
1118 Return if whether has pending recall of not.
1122 sub has_pending_recall {
1125 # FIXME Must be moved to $self->recalls
1126 return Koha::Recalls->search(
1128 item_id => $self->itemnumber,
1129 status => 'waiting',
1134 =head3 as_marc_field
1136 my $field = $item->as_marc_field;
1138 This method returns a MARC::Field object representing the Koha::Item object
1139 with the current mappings configuration.
1146 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1148 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
1152 my $item_field = $tagslib->{$itemtag};
1154 my $more_subfields = $self->additional_attributes->to_hashref;
1155 foreach my $subfield (
1157 $a->{display_order} <=> $b->{display_order}
1158 || $a->{subfield} cmp $b->{subfield}
1159 } grep { ref($_) && %$_ } values %$item_field
1162 my $kohafield = $subfield->{kohafield};
1163 my $tagsubfield = $subfield->{tagsubfield};
1165 if ( defined $kohafield && $kohafield ne '' ) {
1166 next if $kohafield !~ m{^items\.}; # That would be weird!
1167 ( my $attribute = $kohafield ) =~ s|^items\.||;
1168 $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
1169 if defined $self->$attribute and $self->$attribute ne '';
1171 $value = $more_subfields->{$tagsubfield}
1174 next unless defined $value
1177 if ( $subfield->{repeatable} ) {
1178 my @values = split '\|', $value;
1179 push @subfields, ( $tagsubfield => $_ ) for @values;
1182 push @subfields, ( $tagsubfield => $value );
1187 return unless @subfields;
1189 return MARC::Field->new(
1190 "$itemtag", ' ', ' ', @subfields
1194 =head3 renewal_branchcode
1196 Returns the branchcode to be recorded in statistics renewal of the item
1200 sub renewal_branchcode {
1202 my ($self, $params ) = @_;
1204 my $interface = C4::Context->interface;
1206 if ( $interface eq 'opac' ){
1207 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1208 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
1209 $branchcode = 'OPACRenew';
1211 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
1212 $branchcode = $self->homebranch;
1214 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1215 $branchcode = $self->checkout->patron->branchcode;
1217 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1218 $branchcode = $self->checkout->branchcode;
1224 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1225 ? C4::Context->userenv->{branch} : $params->{branch};
1232 Return the cover images associated with this item.
1239 my $cover_image_rs = $self->_result->cover_images;
1240 return unless $cover_image_rs;
1241 return Koha::CoverImages->_new_from_dbic($cover_image_rs);
1244 =head3 columns_to_str
1246 my $values = $items->columns_to_str;
1248 Return a hashref with the string representation of the different attribute of the item.
1250 This is meant to be used for display purpose only.
1254 sub columns_to_str {
1256 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
1257 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
1258 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1260 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1263 for my $column ( @{$self->_columns}) {
1265 next if $column eq 'more_subfields_xml';
1267 my $value = $self->$column;
1268 # 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
1270 if ( not defined $value or $value eq "" ) {
1271 $values->{$column} = $value;
1276 exists $mss->{"items.$column"}
1277 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1280 $values->{$column} =
1282 ? $subfield->{authorised_value}
1283 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1284 $subfield->{tagsubfield}, $value, '', $tagslib )
1290 $self->more_subfields_xml
1291 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1296 my ( $field ) = $marc_more->fields;
1297 for my $sf ( $field->subfields ) {
1298 my $subfield_code = $sf->[0];
1299 my $value = $sf->[1];
1300 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1301 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1303 $subfield->{authorised_value}
1304 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1305 $subfield->{tagsubfield}, $value, '', $tagslib )
1308 push @{$more_values->{$subfield_code}}, $value;
1311 while ( my ( $k, $v ) = each %$more_values ) {
1312 $values->{$k} = join ' | ', @$v;
1319 =head3 additional_attributes
1321 my $attributes = $item->additional_attributes;
1322 $attributes->{k} = 'new k';
1323 $item->update({ more_subfields => $attributes->to_marcxml });
1325 Returns a Koha::Item::Attributes object that represents the non-mapped
1326 attributes for this item.
1330 sub additional_attributes {
1333 return Koha::Item::Attributes->new_from_marcxml(
1334 $self->more_subfields_xml,
1338 =head3 _set_found_trigger
1340 $self->_set_found_trigger
1342 Finds the most recent lost item charge for this item and refunds the patron
1343 appropriately, taking into account any payments or writeoffs already applied
1346 Internal function, not exported, called only by Koha::Item->store.
1350 sub _set_found_trigger {
1351 my ( $self, $pre_mod_item ) = @_;
1353 # Reverse any lost item charges if necessary.
1354 my $no_refund_after_days =
1355 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1356 if ($no_refund_after_days) {
1357 my $today = dt_from_string();
1358 my $lost_age_in_days =
1359 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1362 return $self unless $lost_age_in_days < $no_refund_after_days;
1365 my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
1368 return_branch => C4::Context->userenv
1369 ? C4::Context->userenv->{'branch'}
1373 my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
1375 if ( $lostreturn_policy ) {
1377 # refund charge made for lost book
1378 my $lost_charge = Koha::Account::Lines->search(
1380 itemnumber => $self->itemnumber,
1381 debit_type_code => 'LOST',
1382 status => [ undef, { '<>' => 'FOUND' } ]
1385 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1390 if ( $lost_charge ) {
1392 my $patron = $lost_charge->patron;
1395 my $account = $patron->account;
1397 # Credit outstanding amount
1398 my $credit_total = $lost_charge->amountoutstanding;
1402 $lost_charge->amount > $lost_charge->amountoutstanding &&
1403 $lostreturn_policy ne "refund_unpaid"
1405 # some amount has been cancelled. collect the offsets that are not writeoffs
1406 # this works because the only way to subtract from this kind of a debt is
1407 # using the UI buttons 'Pay' and 'Write off'
1409 # We don't credit any payments if return policy is
1412 # In that case only unpaid/outstanding amount
1413 # will be credited which settles the debt without
1414 # creating extra credits
1416 my $credit_offsets = $lost_charge->debit_offsets(
1418 'credit_id' => { '!=' => undef },
1419 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1421 { join => 'credit' }
1424 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1425 # credits are negative on the DB
1426 $credit_offsets->total * -1 :
1428 # Credit the outstanding amount, then add what has been
1429 # paid to create a net credit for this amount
1430 $credit_total += $total_to_refund;
1434 if ( $credit_total > 0 ) {
1436 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1437 $credit = $account->add_credit(
1439 amount => $credit_total,
1440 description => 'Item found ' . $self->itemnumber,
1441 type => 'LOST_FOUND',
1442 interface => C4::Context->interface,
1443 library_id => $branchcode,
1444 item_id => $self->itemnumber,
1445 issue_id => $lost_charge->issue_id
1449 $credit->apply( { debits => [$lost_charge] } );
1453 message => 'lost_refunded',
1454 payload => { credit_id => $credit->id }
1459 # Update the account status
1460 $lost_charge->status('FOUND');
1461 $lost_charge->store();
1463 # Reconcile balances if required
1464 if ( C4::Context->preference('AccountAutoReconcile') ) {
1465 $account->reconcile_balance;
1470 # possibly restore fine for lost book
1471 my $lost_overdue = Koha::Account::Lines->search(
1473 itemnumber => $self->itemnumber,
1474 debit_type_code => 'OVERDUE',
1478 order_by => { '-desc' => 'date' },
1482 if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
1484 my $patron = $lost_overdue->patron;
1486 my $account = $patron->account;
1488 # Update status of fine
1489 $lost_overdue->status('FOUND')->store();
1491 # Find related forgive credit
1492 my $refund = $lost_overdue->credits(
1494 credit_type_code => 'FORGIVEN',
1495 itemnumber => $self->itemnumber,
1496 status => [ { '!=' => 'VOID' }, undef ]
1498 { order_by => { '-desc' => 'date' }, rows => 1 }
1502 # Revert the forgive credit
1503 $refund->void({ interface => 'trigger' });
1507 message => 'lost_restored',
1508 payload => { refund_id => $refund->id }
1513 # Reconcile balances if required
1514 if ( C4::Context->preference('AccountAutoReconcile') ) {
1515 $account->reconcile_balance;
1519 } elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
1523 message => 'lost_charge',
1529 my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
1531 if ( $processingreturn_policy ) {
1533 # refund processing charge made for lost book
1534 my $processing_charge = Koha::Account::Lines->search(
1536 itemnumber => $self->itemnumber,
1537 debit_type_code => 'PROCESSING',
1538 status => [ undef, { '<>' => 'FOUND' } ]
1541 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1546 if ( $processing_charge ) {
1548 my $patron = $processing_charge->patron;
1551 my $account = $patron->account;
1553 # Credit outstanding amount
1554 my $credit_total = $processing_charge->amountoutstanding;
1558 $processing_charge->amount > $processing_charge->amountoutstanding &&
1559 $processingreturn_policy ne "refund_unpaid"
1561 # some amount has been cancelled. collect the offsets that are not writeoffs
1562 # this works because the only way to subtract from this kind of a debt is
1563 # using the UI buttons 'Pay' and 'Write off'
1565 # We don't credit any payments if return policy is
1568 # In that case only unpaid/outstanding amount
1569 # will be credited which settles the debt without
1570 # creating extra credits
1572 my $credit_offsets = $processing_charge->debit_offsets(
1574 'credit_id' => { '!=' => undef },
1575 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1577 { join => 'credit' }
1580 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1581 # credits are negative on the DB
1582 $credit_offsets->total * -1 :
1584 # Credit the outstanding amount, then add what has been
1585 # paid to create a net credit for this amount
1586 $credit_total += $total_to_refund;
1590 if ( $credit_total > 0 ) {
1592 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1593 $credit = $account->add_credit(
1595 amount => $credit_total,
1596 description => 'Item found ' . $self->itemnumber,
1597 type => 'PROCESSING_FOUND',
1598 interface => C4::Context->interface,
1599 library_id => $branchcode,
1600 item_id => $self->itemnumber,
1601 issue_id => $processing_charge->issue_id
1605 $credit->apply( { debits => [$processing_charge] } );
1609 message => 'processing_refunded',
1610 payload => { credit_id => $credit->id }
1615 # Update the account status
1616 $processing_charge->status('FOUND');
1617 $processing_charge->store();
1619 # Reconcile balances if required
1620 if ( C4::Context->preference('AccountAutoReconcile') ) {
1621 $account->reconcile_balance;
1630 =head3 public_read_list
1632 This method returns the list of publicly readable database fields for both API and UI output purposes
1636 sub public_read_list {
1638 'itemnumber', 'biblionumber', 'homebranch',
1639 'holdingbranch', 'location', 'collectioncode',
1640 'itemcallnumber', 'copynumber', 'enumchron',
1641 'barcode', 'dateaccessioned', 'itemnotes',
1642 'onloan', 'uri', 'itype',
1643 'notforloan', 'damaged', 'itemlost',
1644 'withdrawn', 'restricted'
1650 Overloaded to_api method to ensure item-level itypes is adhered to.
1655 my ($self, $params) = @_;
1657 my $response = $self->SUPER::to_api($params);
1660 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1662 my $itype_notforloan = $self->itemtype->notforloan;
1663 $overrides->{effective_not_for_loan_status} =
1664 ( defined $itype_notforloan && !$self->notforloan ) ? $itype_notforloan : $self->notforloan;
1666 return { %$response, %$overrides };
1669 =head3 to_api_mapping
1671 This method returns the mapping for representing a Koha::Item object
1676 sub to_api_mapping {
1678 itemnumber => 'item_id',
1679 biblionumber => 'biblio_id',
1680 biblioitemnumber => undef,
1681 barcode => 'external_id',
1682 dateaccessioned => 'acquisition_date',
1683 booksellerid => 'acquisition_source',
1684 homebranch => 'home_library_id',
1685 price => 'purchase_price',
1686 replacementprice => 'replacement_price',
1687 replacementpricedate => 'replacement_price_date',
1688 datelastborrowed => 'last_checkout_date',
1689 datelastseen => 'last_seen_date',
1691 notforloan => 'not_for_loan_status',
1692 damaged => 'damaged_status',
1693 damaged_on => 'damaged_date',
1694 itemlost => 'lost_status',
1695 itemlost_on => 'lost_date',
1696 withdrawn => 'withdrawn',
1697 withdrawn_on => 'withdrawn_date',
1698 itemcallnumber => 'callnumber',
1699 coded_location_qualifier => 'coded_location_qualifier',
1700 issues => 'checkouts_count',
1701 renewals => 'renewals_count',
1702 reserves => 'holds_count',
1703 restricted => 'restricted_status',
1704 itemnotes => 'public_notes',
1705 itemnotes_nonpublic => 'internal_notes',
1706 holdingbranch => 'holding_library_id',
1707 timestamp => 'timestamp',
1708 location => 'location',
1709 permanent_location => 'permanent_location',
1710 onloan => 'checked_out_date',
1711 cn_source => 'call_number_source',
1712 cn_sort => 'call_number_sort',
1713 ccode => 'collection_code',
1714 materials => 'materials_notes',
1716 itype => 'item_type_id',
1717 more_subfields_xml => 'extended_subfields',
1718 enumchron => 'serial_issue_number',
1719 copynumber => 'copy_number',
1720 stocknumber => 'inventory_number',
1721 new_status => 'new_status',
1722 deleted_on => undef,
1728 my $itemtype = $item->itemtype;
1730 Returns Koha object for effective itemtype
1737 return Koha::ItemTypes->find( $self->effective_itemtype );
1742 my $orders = $item->orders();
1744 Returns a Koha::Acquisition::Orders object
1751 my $orders = $self->_result->item_orders;
1752 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1755 =head3 tracked_links
1757 my $tracked_links = $item->tracked_links();
1759 Returns a Koha::TrackedLinks object
1766 my $tracked_links = $self->_result->linktrackers;
1767 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1770 =head3 move_to_biblio
1772 $item->move_to_biblio($to_biblio[, $params]);
1774 Move the item to another biblio and update any references in other tables.
1776 The final optional parameter, C<$params>, is expected to contain the
1777 'skip_record_index' key, which is relayed down to Koha::Item->store.
1778 There it prevents calling index_records, which takes most of the
1779 time in batch adds/deletes. The caller must take care of calling
1780 index_records separately.
1783 skip_record_index => 1|0
1785 Returns undef if the move failed or the biblionumber of the destination record otherwise
1789 sub move_to_biblio {
1790 my ( $self, $to_biblio, $params ) = @_;
1794 return if $self->biblionumber == $to_biblio->biblionumber;
1796 my $from_biblionumber = $self->biblionumber;
1797 my $to_biblionumber = $to_biblio->biblionumber;
1799 # Own biblionumber and biblioitemnumber
1801 biblionumber => $to_biblionumber,
1802 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1803 })->store({ skip_record_index => $params->{skip_record_index} });
1805 unless ($params->{skip_record_index}) {
1806 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1807 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1810 # Acquisition orders
1811 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1814 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1816 # hold_fill_target (there's no Koha object available yet)
1817 my $hold_fill_target = $self->_result->hold_fill_target;
1818 if ($hold_fill_target) {
1819 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1822 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1823 # and can't even fake one since the significant columns are nullable.
1824 my $storage = $self->_result->result_source->storage;
1827 my ($storage, $dbh, @cols) = @_;
1829 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1834 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1836 return $to_biblionumber;
1841 my $bundle_items = $item->bundle_items;
1843 Returns the items associated with this bundle
1850 my $rs = $self->_result->bundle_items;
1851 return Koha::Items->_new_from_dbic($rs);
1856 my $is_bundle = $item->is_bundle;
1858 Returns whether the item is a bundle or not
1864 return $self->bundle_items->count ? 1 : 0;
1869 my $bundle = $item->bundle_host;
1871 Returns the bundle item this item is attached to
1878 my $bundle_items_rs = $self->_result->item_bundles_item;
1879 return unless $bundle_items_rs;
1880 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1885 my $in_bundle = $item->in_bundle;
1887 Returns whether this item is currently in a bundle
1893 return $self->bundle_host ? 1 : 0;
1896 =head3 add_to_bundle
1898 my $link = $item->add_to_bundle($bundle_item);
1900 Adds the bundle_item passed to this item
1905 my ( $self, $bundle_item, $options ) = @_;
1909 Koha::Exceptions::Item::Bundle::IsBundle->throw()
1910 if ( $self->itemnumber eq $bundle_item->itemnumber
1911 || $bundle_item->is_bundle
1912 || $self->in_bundle );
1914 my $schema = Koha::Database->new->schema;
1916 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1922 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
1924 my $checkout = $bundle_item->checkout;
1926 unless ($options->{force_checkin}) {
1927 Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
1930 my $branchcode = C4::Context->userenv->{'branch'};
1931 my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
1933 Koha::Exceptions::Checkin::FailedCheckin->throw();
1937 my $holds = $bundle_item->current_holds;
1938 if ($holds->count) {
1939 unless ($options->{ignore_holds}) {
1940 Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
1944 $self->_result->add_to_item_bundles_hosts(
1945 { item => $bundle_item->itemnumber } );
1947 $bundle_item->notforloan($BundleNotLoanValue)->store();
1953 # 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
1954 if ( ref($_) eq 'DBIx::Class::Exception' ) {
1955 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1957 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1958 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1959 Koha::Exceptions::Object::FKConstraint->throw(
1960 error => 'Broken FK constraint',
1961 broken_fk => $+{column}
1966 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
1968 Koha::Exceptions::Object::DuplicateID->throw(
1969 error => 'Duplicate ID',
1970 duplicate_id => $+{key}
1973 elsif ( $_->{msg} =~
1974 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
1976 { # The optional \W in the regex might be a quote or backtick
1977 my $type = $+{type};
1978 my $value = $+{value};
1979 my $property = $+{property};
1980 $property =~ s/['`]//g;
1981 Koha::Exceptions::Object::BadValue->throw(
1984 property => $property =~ /(\w+\.\w+)$/
1987 , # results in table.column without quotes or backtics
1991 # Catch-all for foreign key breakages. It will help find other use cases
2000 =head3 remove_from_bundle
2002 Remove this item from any bundle it may have been attached to.
2006 sub remove_from_bundle {
2009 my $bundle_host = $self->bundle_host;
2011 return 0 unless $bundle_host; # Should not we raise an exception here?
2013 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $bundle_host->checkout;
2015 my $bundle_item_rs = $self->_result->item_bundles_item;
2016 if ( $bundle_item_rs ) {
2017 $bundle_item_rs->delete;
2018 $self->notforloan(0)->store();
2024 =head2 Internal methods
2026 =head3 _after_item_action_hooks
2028 Helper method that takes care of calling all plugin hooks
2032 sub _after_item_action_hooks {
2033 my ( $self, $params ) = @_;
2035 my $action = $params->{action};
2037 Koha::Plugins->call(
2038 'after_item_action',
2042 item_id => $self->itemnumber,
2049 my $recall = $item->recall;
2051 Return the relevant recall for this item
2057 my @recalls = Koha::Recalls->search(
2059 biblio_id => $self->biblionumber,
2062 { order_by => { -asc => 'created_date' } }
2065 my $item_level_recall;
2066 foreach my $recall (@recalls) {
2067 if ( $recall->item_level ) {
2068 $item_level_recall = 1;
2069 if ( $recall->item_id == $self->itemnumber ) {
2074 if ($item_level_recall) {
2076 # recall needs to be filled be a specific item only
2077 # no other item is relevant to return
2081 # no item-level recall to return, so return earliest biblio-level
2082 # FIXME: eventually this will be based on priority
2086 =head3 can_be_recalled
2088 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
2090 Does item-level checks and returns if items can be recalled by this borrower
2094 sub can_be_recalled {
2095 my ( $self, $params ) = @_;
2097 return 0 if !( C4::Context->preference('UseRecalls') );
2099 # check if this item is not for loan, withdrawn or lost
2100 return 0 if ( $self->notforloan != 0 );
2101 return 0 if ( $self->itemlost != 0 );
2102 return 0 if ( $self->withdrawn != 0 );
2104 # check if this item is not checked out - if not checked out, can't be recalled
2105 return 0 if ( !defined( $self->checkout ) );
2107 my $patron = $params->{patron};
2109 my $branchcode = C4::Context->userenv->{'branch'};
2111 $branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
2114 # Check the circulation rule for each relevant itemtype for this item
2115 my $rule = Koha::CirculationRules->get_effective_rules({
2116 branchcode => $branchcode,
2117 categorycode => $patron ? $patron->categorycode : undef,
2118 itemtype => $self->effective_itemtype,
2121 'recalls_per_record',
2126 # check recalls allowed has been set and is not zero
2127 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2130 # check borrower has not reached open recalls allowed limit
2131 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
2133 # check borrower has not reach open recalls allowed per record limit
2134 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
2136 # check if this patron has already recalled this item
2137 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
2139 # check if this patron has already checked out this item
2140 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2142 # check if this patron has already reserved this item
2143 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2146 # check item availability
2147 # items are unavailable for recall if they are lost, withdrawn or notforloan
2148 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
2150 # if there are no available items at all, no recall can be placed
2151 return 0 if ( scalar @items == 0 );
2153 my $checked_out_count = 0;
2155 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
2158 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
2159 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
2161 # can't recall if no items have been checked out
2162 return 0 if ( $checked_out_count == 0 );
2168 =head3 can_be_waiting_recall
2170 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
2172 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
2173 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
2177 sub can_be_waiting_recall {
2180 return 0 if !( C4::Context->preference('UseRecalls') );
2182 # check if this item is not for loan, withdrawn or lost
2183 return 0 if ( $self->notforloan != 0 );
2184 return 0 if ( $self->itemlost != 0 );
2185 return 0 if ( $self->withdrawn != 0 );
2187 my $branchcode = $self->holdingbranch;
2188 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
2189 $branchcode = C4::Context->userenv->{'branch'};
2191 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
2194 # Check the circulation rule for each relevant itemtype for this item
2195 my $most_relevant_recall = $self->check_recalls;
2196 my $rule = Koha::CirculationRules->get_effective_rules(
2198 branchcode => $branchcode,
2199 categorycode => $most_relevant_recall ? $most_relevant_recall->patron->categorycode : undef,
2200 itemtype => $self->effective_itemtype,
2201 rules => [ 'recalls_allowed', ],
2205 # check recalls allowed has been set and is not zero
2206 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2212 =head3 check_recalls
2214 my $recall = $item->check_recalls;
2216 Get the most relevant recall for this item.
2223 my @recalls = Koha::Recalls->search(
2224 { biblio_id => $self->biblionumber,
2225 item_id => [ $self->itemnumber, undef ]
2227 { order_by => { -asc => 'created_date' } }
2228 )->filter_by_current->as_list;
2231 # iterate through relevant recalls to find the best one.
2232 # if we come across a waiting recall, use this one.
2233 # 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.
2234 foreach my $r ( @recalls ) {
2235 if ( $r->waiting ) {
2240 unless ( defined $recall ) {
2241 $recall = $recalls[0];
2247 =head3 is_notforloan
2249 my $is_notforloan = $item->is_notforloan;
2251 Determine whether or not this item is "notforloan" based on
2252 the item's notforloan status or its item type
2258 my $is_notforloan = 0;
2260 if ( $self->notforloan ){
2264 my $itemtype = $self->itemtype;
2266 if ( $itemtype->notforloan ){
2272 return $is_notforloan;
2275 =head3 is_denied_renewal
2277 my $is_denied_renewal = $item->is_denied_renewal;
2279 Determine whether or not this item can be renewed based on the
2280 rules set in the ItemsDeniedRenewal system preference.
2284 sub is_denied_renewal {
2286 my $denyingrules = C4::Context->yaml_preference('ItemsDeniedRenewal');
2287 return 0 unless $denyingrules;
2288 foreach my $field (keys %$denyingrules) {
2289 # Silently ignore bad column names; TODO we should validate elsewhere
2290 next if !$self->_result->result_source->has_column($field);
2291 my $val = $self->$field;
2292 if( !defined $val) {
2293 if ( any { !defined $_ } @{$denyingrules->{$field}} ){
2296 } elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
2297 # If the results matches the values in the syspref
2298 # We return true if match found
2307 Returns a map of column name to string representations including the string,
2308 the mapping type and the mapping category where appropriate.
2310 Currently handles authorised value mappings, library, callnumber and itemtype
2313 Accepts a param hashref where the 'public' key denotes whether we want the public
2314 or staff client strings.
2319 my ( $self, $params ) = @_;
2320 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
2321 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
2322 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
2324 my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
2326 # Hardcoded known 'authorised_value' values mapped to API codes
2327 my $code_to_type = {
2328 branches => 'library',
2329 cn_source => 'call_number_source',
2330 itemtypes => 'item_type',
2333 # Handle not null and default values for integers and dates
2336 foreach my $col ( @{$self->_columns} ) {
2338 # By now, we are done with known columns, now check the framework for mappings
2339 my $field = $self->_result->result_source->name . '.' . $col;
2341 # Check there's an entry in the MARC subfield structure for the field
2342 if ( exists $mss->{$field}
2343 && scalar @{ $mss->{$field} } > 0
2344 && $mss->{$field}[0]->{authorised_value} )
2346 my $subfield = $mss->{$field}[0];
2347 my $code = $subfield->{authorised_value};
2349 my $str = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
2350 my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
2351 $strings->{$col} = {
2354 ( $type eq 'av' ? ( category => $code ) : () ),
2372 Kyle M Hall <kyle@bywatersolutions.com>