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) }
567 { order_by => { '-asc' => 'start_date' } }
571 my $loanable_items = {};
572 my $bookable_items = $biblio->bookable_items;
573 while ( my $item = $bookable_items->next ) {
574 $loanable_items->{ $item->itemnumber } = 1;
575 if ( my $checkout = $item->checkout ) {
576 $checkouts->{ $item->itemnumber } = dt_from_string( $checkout->date_due );
580 while ( my $booking = $bookings->next ) {
582 # Booking for this item
583 if ( defined( $booking->item_id )
584 && $booking->item_id == $self->itemnumber )
589 # Booking for another item
590 elsif ( defined( $booking->item_id ) ) {
591 # Due for another booking, remove from pool
592 delete $loanable_items->{ $booking->item_id };
597 # Booking for any item
599 # Can another item satisfy this booking?
608 $item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
610 Returns a boolean denoting whether the passed booking can be made without clashing.
612 Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
617 my ( $self, $params ) = @_;
619 my $start_date = dt_from_string( $params->{start_date} );
620 my $end_date = dt_from_string( $params->{end_date} );
621 my $booking_id = $params->{booking_id};
623 if ( my $checkout = $self->checkout ) {
624 return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
627 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
629 my $existing_bookings = $self->bookings(
633 $dtf->format_datetime($start_date),
634 $dtf->format_datetime($end_date)
639 $dtf->format_datetime($start_date),
640 $dtf->format_datetime($end_date)
644 start_date => { '<' => $dtf->format_datetime($start_date) },
645 end_date => { '>' => $dtf->format_datetime($end_date) }
652 ? $existing_bookings->search( { booking_id => { '!=' => $booking_id } } )->count
653 : $existing_bookings->count;
655 return $bookings_count ? 0 : 1;
660 my $booking = $item->place_booking(
663 start_date => $datetime,
664 end_date => $datetime
668 Add a booking for this item for the dates passed.
670 Returns the Koha::Booking object or throws an exception if the item cannot be booked for the given dates.
675 my ( $self, $params ) = @_;
677 # check for mandatory params
678 my @mandatory = ( 'start_date', 'end_date', 'patron' );
679 for my $param (@mandatory) {
680 unless ( defined( $params->{$param} ) ) {
681 Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
684 my $patron = $params->{patron};
687 my $booking = Koha::Booking->new(
689 start_date => $params->{start_date},
690 end_date => $params->{end_date},
691 patron_id => $patron->borrowernumber,
692 biblio_id => $self->biblionumber,
693 item_id => $self->itemnumber,
699 =head3 request_transfer
701 my $transfer = $item->request_transfer(
705 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
709 Add a transfer request for this item to the given branch for the given reason.
711 An exception will be thrown if the BranchTransferLimits would prevent the requested
712 transfer, unless 'ignore_limits' is passed to override the limits.
714 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
715 The caller should catch such cases and retry the transfer request as appropriate passing
716 an appropriate override.
719 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
720 * replace - Used to replace the existing transfer request with your own.
724 sub request_transfer {
725 my ( $self, $params ) = @_;
727 # check for mandatory params
728 my @mandatory = ( 'to', 'reason' );
729 for my $param (@mandatory) {
730 unless ( defined( $params->{$param} ) ) {
731 Koha::Exceptions::MissingParameter->throw(
732 error => "The $param parameter is mandatory" );
736 Koha::Exceptions::Item::Transfer::Limit->throw()
737 unless ( $params->{ignore_limits}
738 || $self->can_be_transferred( { to => $params->{to} } ) );
740 my $request = $self->get_transfer;
741 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
742 if ( $request && !$params->{enqueue} && !$params->{replace} );
744 $request->cancel( { reason => $params->{reason}, force => 1 } )
745 if ( defined($request) && $params->{replace} );
747 my $transfer = Koha::Item::Transfer->new(
749 itemnumber => $self->itemnumber,
750 daterequested => dt_from_string,
751 frombranch => $self->holdingbranch,
752 tobranch => $params->{to}->branchcode,
753 reason => $params->{reason},
754 comments => $params->{comment}
763 my $transfer = $item->get_transfer;
765 Return the active transfer request or undef
767 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
768 whereby the most recently sent, but not received, transfer will be returned
769 if it exists, otherwise the oldest unsatisfied transfer will be returned.
771 This allows for transfers to queue, which is the case for stock rotation and
772 rotating collections where a manual transfer may need to take precedence but
773 we still expect the item to end up at a final location eventually.
780 my $transfer = $self->_result->current_branchtransfers->next;
781 return Koha::Item::Transfer->_new_from_dbic($transfer) if $transfer;
786 my $transfer = $item->get_transfers;
788 Return the list of outstanding transfers (i.e requested but not yet cancelled
791 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
792 whereby the most recently sent, but not received, transfer will be returned
793 first if it exists, otherwise requests are in oldest to newest request order.
795 This allows for transfers to queue, which is the case for stock rotation and
796 rotating collections where a manual transfer may need to take precedence but
797 we still expect the item to end up at a final location eventually.
804 my $transfer_rs = $self->_result->current_branchtransfers;
806 return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
809 =head3 last_returned_by
811 Gets and sets the last patron to return an item.
813 Accepts a patron's id (borrowernumber) and returns Koha::Patron objects
815 $item->last_returned_by( $borrowernumber );
817 my $patron = $item->last_returned_by();
821 sub last_returned_by {
822 my ( $self, $borrowernumber ) = @_;
823 if ( $borrowernumber ) {
824 $self->_result->update_or_create_related('last_returned_by',
825 { borrowernumber => $borrowernumber, itemnumber => $self->itemnumber } );
827 my $rs = $self->_result->last_returned_by;
829 return Koha::Patron->_new_from_dbic($rs->borrowernumber);
832 =head3 can_article_request
834 my $bool = $item->can_article_request( $borrower )
836 Returns true if item can be specifically requested
838 $borrower must be a Koha::Patron object
842 sub can_article_request {
843 my ( $self, $borrower ) = @_;
845 my $rule = $self->article_request_type($borrower);
847 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
851 =head3 hidden_in_opac
853 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
855 Returns true if item fields match the hidding criteria defined in $rules.
856 Returns false otherwise.
858 Takes HASHref that can have the following parameters:
860 $rules : { <field> => [ value_1, ... ], ... }
862 Note: $rules inherits its structure from the parsed YAML from reading
863 the I<OpacHiddenItems> system preference.
868 my ( $self, $params ) = @_;
870 my $rules = $params->{rules} // {};
873 if C4::Context->preference('hidelostitems') and
876 my $hidden_in_opac = 0;
878 foreach my $field ( keys %{$rules} ) {
880 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
886 return $hidden_in_opac;
889 =head3 can_be_transferred
891 $item->can_be_transferred({ to => $to_library, from => $from_library })
892 Checks if an item can be transferred to given library.
894 This feature is controlled by two system preferences:
895 UseBranchTransferLimits to enable / disable the feature
896 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
897 for setting the limitations
899 Takes HASHref that can have the following parameters:
900 MANDATORY PARAMETERS:
903 $from : Koha::Library # if not given, item holdingbranch
904 # will be used instead
906 Returns 1 if item can be transferred to $to_library, otherwise 0.
908 To find out whether at least one item of a Koha::Biblio can be transferred, please
909 see Koha::Biblio->can_be_transferred() instead of using this method for
910 multiple items of the same biblio.
914 sub can_be_transferred {
915 my ($self, $params) = @_;
917 my $to = $params->{to};
918 my $from = $params->{from};
920 $to = $to->branchcode;
921 $from = defined $from ? $from->branchcode : $self->holdingbranch;
923 return 1 if $from eq $to; # Transfer to current branch is allowed
924 return 1 unless C4::Context->preference('UseBranchTransferLimits');
926 my $limittype = C4::Context->preference('BranchTransferLimitsType');
927 return Koha::Item::Transfer::Limits->search({
930 $limittype => $limittype eq 'itemtype'
931 ? $self->effective_itemtype : $self->ccode
936 =head3 pickup_locations
938 my $pickup_locations = $item->pickup_locations({ patron => $patron })
940 Returns possible pickup locations for this item, according to patron's home library
941 and if item can be transferred to each pickup location.
943 Throws a I<Koha::Exceptions::MissingParameter> exception if the B<mandatory> parameter I<patron>
948 sub pickup_locations {
949 my ($self, $params) = @_;
951 Koha::Exceptions::MissingParameter->throw( parameter => 'patron' )
952 unless exists $params->{patron};
954 my $patron = $params->{patron};
956 my $circ_control_branch = Koha::Policy::Holds->holds_control_library( $self, $patron );
958 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
960 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
961 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
963 my $pickup_libraries = Koha::Libraries->search();
964 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
965 $pickup_libraries = $self->home_branch->get_hold_libraries;
966 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
967 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
968 $pickup_libraries = $plib->get_hold_libraries;
969 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
970 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
971 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
972 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
975 return $pickup_libraries->search(
980 order_by => ['branchname']
982 ) unless C4::Context->preference('UseBranchTransferLimits');
984 my $limittype = C4::Context->preference('BranchTransferLimitsType');
985 my ($ccode, $itype) = (undef, undef);
986 if( $limittype eq 'ccode' ){
987 $ccode = $self->ccode;
989 $itype = $self->itype;
991 my $limits = Koha::Item::Transfer::Limits->search(
993 fromBranch => $self->holdingbranch,
997 { columns => ['toBranch'] }
1000 return $pickup_libraries->search(
1002 pickup_location => 1,
1004 '-not_in' => $limits->_resultset->as_query
1008 order_by => ['branchname']
1013 =head3 article_request_type
1015 my $type = $item->article_request_type( $borrower )
1017 returns 'yes', 'no', 'bib_only', or 'item_only'
1019 $borrower must be a Koha::Patron object
1023 sub article_request_type {
1024 my ( $self, $borrower ) = @_;
1026 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
1028 $branch_control eq 'homebranch' ? $self->homebranch
1029 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
1031 my $borrowertype = $borrower->categorycode;
1032 my $itemtype = $self->effective_itemtype();
1033 my $rule = Koha::CirculationRules->get_effective_rule(
1035 rule_name => 'article_requests',
1036 categorycode => $borrowertype,
1037 itemtype => $itemtype,
1038 branchcode => $branchcode
1042 return q{} unless $rule;
1043 return $rule->rule_value || q{}
1046 =head3 current_holds
1052 my $attributes = { order_by => 'priority' };
1053 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1055 itemnumber => $self->itemnumber,
1058 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
1059 waitingdate => { '!=' => undef },
1062 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
1063 return Koha::Holds->_new_from_dbic($hold_rs);
1066 =head3 stockrotationitem
1068 my $sritem = Koha::Item->stockrotationitem;
1070 Returns the stock rotation item associated with the current item.
1074 sub stockrotationitem {
1076 my $rs = $self->_result->stockrotationitem;
1078 return Koha::StockRotationItem->_new_from_dbic( $rs );
1083 my $item = $item->add_to_rota($rota_id);
1085 Add this item to the rota identified by $ROTA_ID, which means associating it
1086 with the first stage of that rota. Should this item already be associated
1087 with a rota, then we will move it to the new rota.
1092 my ( $self, $rota_id ) = @_;
1093 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
1097 =head3 has_pending_hold
1099 my $is_pending_hold = $item->has_pending_hold();
1101 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
1105 sub has_pending_hold {
1107 return $self->_result->tmp_holdsqueue ? 1 : 0;
1110 =head3 has_pending_recall {
1112 my $has_pending_recall
1114 Return if whether has pending recall of not.
1118 sub has_pending_recall {
1121 # FIXME Must be moved to $self->recalls
1122 return Koha::Recalls->search(
1124 item_id => $self->itemnumber,
1125 status => 'waiting',
1130 =head3 as_marc_field
1132 my $field = $item->as_marc_field;
1134 This method returns a MARC::Field object representing the Koha::Item object
1135 with the current mappings configuration.
1142 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1144 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
1148 my $item_field = $tagslib->{$itemtag};
1150 my $more_subfields = $self->additional_attributes->to_hashref;
1151 foreach my $subfield (
1153 $a->{display_order} <=> $b->{display_order}
1154 || $a->{subfield} cmp $b->{subfield}
1155 } grep { ref($_) && %$_ } values %$item_field
1158 my $kohafield = $subfield->{kohafield};
1159 my $tagsubfield = $subfield->{tagsubfield};
1161 if ( defined $kohafield && $kohafield ne '' ) {
1162 next if $kohafield !~ m{^items\.}; # That would be weird!
1163 ( my $attribute = $kohafield ) =~ s|^items\.||;
1164 $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
1165 if defined $self->$attribute and $self->$attribute ne '';
1167 $value = $more_subfields->{$tagsubfield}
1170 next unless defined $value
1173 if ( $subfield->{repeatable} ) {
1174 my @values = split '\|', $value;
1175 push @subfields, ( $tagsubfield => $_ ) for @values;
1178 push @subfields, ( $tagsubfield => $value );
1183 return unless @subfields;
1185 return MARC::Field->new(
1186 "$itemtag", ' ', ' ', @subfields
1190 =head3 renewal_branchcode
1192 Returns the branchcode to be recorded in statistics renewal of the item
1196 sub renewal_branchcode {
1198 my ($self, $params ) = @_;
1200 my $interface = C4::Context->interface;
1202 if ( $interface eq 'opac' ){
1203 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1204 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
1205 $branchcode = 'OPACRenew';
1207 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
1208 $branchcode = $self->homebranch;
1210 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1211 $branchcode = $self->checkout->patron->branchcode;
1213 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1214 $branchcode = $self->checkout->branchcode;
1220 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1221 ? C4::Context->userenv->{branch} : $params->{branch};
1228 Return the cover images associated with this item.
1235 my $cover_image_rs = $self->_result->cover_images;
1236 return unless $cover_image_rs;
1237 return Koha::CoverImages->_new_from_dbic($cover_image_rs);
1240 =head3 columns_to_str
1242 my $values = $items->columns_to_str;
1244 Return a hashref with the string representation of the different attribute of the item.
1246 This is meant to be used for display purpose only.
1250 sub columns_to_str {
1252 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
1253 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
1254 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1256 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1259 for my $column ( @{$self->_columns}) {
1261 next if $column eq 'more_subfields_xml';
1263 my $value = $self->$column;
1264 # 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
1266 if ( not defined $value or $value eq "" ) {
1267 $values->{$column} = $value;
1272 exists $mss->{"items.$column"}
1273 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1276 $values->{$column} =
1278 ? $subfield->{authorised_value}
1279 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1280 $subfield->{tagsubfield}, $value, '', $tagslib )
1286 $self->more_subfields_xml
1287 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1292 my ( $field ) = $marc_more->fields;
1293 for my $sf ( $field->subfields ) {
1294 my $subfield_code = $sf->[0];
1295 my $value = $sf->[1];
1296 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1297 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1299 $subfield->{authorised_value}
1300 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1301 $subfield->{tagsubfield}, $value, '', $tagslib )
1304 push @{$more_values->{$subfield_code}}, $value;
1307 while ( my ( $k, $v ) = each %$more_values ) {
1308 $values->{$k} = join ' | ', @$v;
1315 =head3 additional_attributes
1317 my $attributes = $item->additional_attributes;
1318 $attributes->{k} = 'new k';
1319 $item->update({ more_subfields => $attributes->to_marcxml });
1321 Returns a Koha::Item::Attributes object that represents the non-mapped
1322 attributes for this item.
1326 sub additional_attributes {
1329 return Koha::Item::Attributes->new_from_marcxml(
1330 $self->more_subfields_xml,
1334 =head3 _set_found_trigger
1336 $self->_set_found_trigger
1338 Finds the most recent lost item charge for this item and refunds the patron
1339 appropriately, taking into account any payments or writeoffs already applied
1342 Internal function, not exported, called only by Koha::Item->store.
1346 sub _set_found_trigger {
1347 my ( $self, $pre_mod_item ) = @_;
1349 # Reverse any lost item charges if necessary.
1350 my $no_refund_after_days =
1351 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1352 if ($no_refund_after_days) {
1353 my $today = dt_from_string();
1354 my $lost_age_in_days =
1355 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1358 return $self unless $lost_age_in_days < $no_refund_after_days;
1361 my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
1364 return_branch => C4::Context->userenv
1365 ? C4::Context->userenv->{'branch'}
1369 my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
1371 if ( $lostreturn_policy ) {
1373 # refund charge made for lost book
1374 my $lost_charge = Koha::Account::Lines->search(
1376 itemnumber => $self->itemnumber,
1377 debit_type_code => 'LOST',
1378 status => [ undef, { '<>' => 'FOUND' } ]
1381 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1386 if ( $lost_charge ) {
1388 my $patron = $lost_charge->patron;
1391 my $account = $patron->account;
1393 # Credit outstanding amount
1394 my $credit_total = $lost_charge->amountoutstanding;
1398 $lost_charge->amount > $lost_charge->amountoutstanding &&
1399 $lostreturn_policy ne "refund_unpaid"
1401 # some amount has been cancelled. collect the offsets that are not writeoffs
1402 # this works because the only way to subtract from this kind of a debt is
1403 # using the UI buttons 'Pay' and 'Write off'
1405 # We don't credit any payments if return policy is
1408 # In that case only unpaid/outstanding amount
1409 # will be credited which settles the debt without
1410 # creating extra credits
1412 my $credit_offsets = $lost_charge->debit_offsets(
1414 'credit_id' => { '!=' => undef },
1415 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1417 { join => 'credit' }
1420 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1421 # credits are negative on the DB
1422 $credit_offsets->total * -1 :
1424 # Credit the outstanding amount, then add what has been
1425 # paid to create a net credit for this amount
1426 $credit_total += $total_to_refund;
1430 if ( $credit_total > 0 ) {
1432 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1433 $credit = $account->add_credit(
1435 amount => $credit_total,
1436 description => 'Item found ' . $self->itemnumber,
1437 type => 'LOST_FOUND',
1438 interface => C4::Context->interface,
1439 library_id => $branchcode,
1440 item_id => $self->itemnumber,
1441 issue_id => $lost_charge->issue_id
1445 $credit->apply( { debits => [$lost_charge] } );
1449 message => 'lost_refunded',
1450 payload => { credit_id => $credit->id }
1455 # Update the account status
1456 $lost_charge->status('FOUND');
1457 $lost_charge->store();
1459 # Reconcile balances if required
1460 if ( C4::Context->preference('AccountAutoReconcile') ) {
1461 $account->reconcile_balance;
1466 # possibly restore fine for lost book
1467 my $lost_overdue = Koha::Account::Lines->search(
1469 itemnumber => $self->itemnumber,
1470 debit_type_code => 'OVERDUE',
1474 order_by => { '-desc' => 'date' },
1478 if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
1480 my $patron = $lost_overdue->patron;
1482 my $account = $patron->account;
1484 # Update status of fine
1485 $lost_overdue->status('FOUND')->store();
1487 # Find related forgive credit
1488 my $refund = $lost_overdue->credits(
1490 credit_type_code => 'FORGIVEN',
1491 itemnumber => $self->itemnumber,
1492 status => [ { '!=' => 'VOID' }, undef ]
1494 { order_by => { '-desc' => 'date' }, rows => 1 }
1498 # Revert the forgive credit
1499 $refund->void({ interface => 'trigger' });
1503 message => 'lost_restored',
1504 payload => { refund_id => $refund->id }
1509 # Reconcile balances if required
1510 if ( C4::Context->preference('AccountAutoReconcile') ) {
1511 $account->reconcile_balance;
1515 } elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
1519 message => 'lost_charge',
1525 my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
1527 if ( $processingreturn_policy ) {
1529 # refund processing charge made for lost book
1530 my $processing_charge = Koha::Account::Lines->search(
1532 itemnumber => $self->itemnumber,
1533 debit_type_code => 'PROCESSING',
1534 status => [ undef, { '<>' => 'FOUND' } ]
1537 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1542 if ( $processing_charge ) {
1544 my $patron = $processing_charge->patron;
1547 my $account = $patron->account;
1549 # Credit outstanding amount
1550 my $credit_total = $processing_charge->amountoutstanding;
1554 $processing_charge->amount > $processing_charge->amountoutstanding &&
1555 $processingreturn_policy ne "refund_unpaid"
1557 # some amount has been cancelled. collect the offsets that are not writeoffs
1558 # this works because the only way to subtract from this kind of a debt is
1559 # using the UI buttons 'Pay' and 'Write off'
1561 # We don't credit any payments if return policy is
1564 # In that case only unpaid/outstanding amount
1565 # will be credited which settles the debt without
1566 # creating extra credits
1568 my $credit_offsets = $processing_charge->debit_offsets(
1570 'credit_id' => { '!=' => undef },
1571 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1573 { join => 'credit' }
1576 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1577 # credits are negative on the DB
1578 $credit_offsets->total * -1 :
1580 # Credit the outstanding amount, then add what has been
1581 # paid to create a net credit for this amount
1582 $credit_total += $total_to_refund;
1586 if ( $credit_total > 0 ) {
1588 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1589 $credit = $account->add_credit(
1591 amount => $credit_total,
1592 description => 'Item found ' . $self->itemnumber,
1593 type => 'PROCESSING_FOUND',
1594 interface => C4::Context->interface,
1595 library_id => $branchcode,
1596 item_id => $self->itemnumber,
1597 issue_id => $processing_charge->issue_id
1601 $credit->apply( { debits => [$processing_charge] } );
1605 message => 'processing_refunded',
1606 payload => { credit_id => $credit->id }
1611 # Update the account status
1612 $processing_charge->status('FOUND');
1613 $processing_charge->store();
1615 # Reconcile balances if required
1616 if ( C4::Context->preference('AccountAutoReconcile') ) {
1617 $account->reconcile_balance;
1626 =head3 public_read_list
1628 This method returns the list of publicly readable database fields for both API and UI output purposes
1632 sub public_read_list {
1634 'itemnumber', 'biblionumber', 'homebranch',
1635 'holdingbranch', 'location', 'collectioncode',
1636 'itemcallnumber', 'copynumber', 'enumchron',
1637 'barcode', 'dateaccessioned', 'itemnotes',
1638 'onloan', 'uri', 'itype',
1639 'notforloan', 'damaged', 'itemlost',
1640 'withdrawn', 'restricted'
1646 Overloaded to_api method to ensure item-level itypes is adhered to.
1651 my ($self, $params) = @_;
1653 my $response = $self->SUPER::to_api($params);
1656 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1658 my $itype_notforloan = $self->itemtype->notforloan;
1659 $overrides->{effective_not_for_loan_status} =
1660 ( defined $itype_notforloan && !$self->notforloan ) ? $itype_notforloan : $self->notforloan;
1662 return { %$response, %$overrides };
1665 =head3 to_api_mapping
1667 This method returns the mapping for representing a Koha::Item object
1672 sub to_api_mapping {
1674 itemnumber => 'item_id',
1675 biblionumber => 'biblio_id',
1676 biblioitemnumber => undef,
1677 barcode => 'external_id',
1678 dateaccessioned => 'acquisition_date',
1679 booksellerid => 'acquisition_source',
1680 homebranch => 'home_library_id',
1681 price => 'purchase_price',
1682 replacementprice => 'replacement_price',
1683 replacementpricedate => 'replacement_price_date',
1684 datelastborrowed => 'last_checkout_date',
1685 datelastseen => 'last_seen_date',
1687 notforloan => 'not_for_loan_status',
1688 damaged => 'damaged_status',
1689 damaged_on => 'damaged_date',
1690 itemlost => 'lost_status',
1691 itemlost_on => 'lost_date',
1692 withdrawn => 'withdrawn',
1693 withdrawn_on => 'withdrawn_date',
1694 itemcallnumber => 'callnumber',
1695 coded_location_qualifier => 'coded_location_qualifier',
1696 issues => 'checkouts_count',
1697 renewals => 'renewals_count',
1698 reserves => 'holds_count',
1699 restricted => 'restricted_status',
1700 itemnotes => 'public_notes',
1701 itemnotes_nonpublic => 'internal_notes',
1702 holdingbranch => 'holding_library_id',
1703 timestamp => 'timestamp',
1704 location => 'location',
1705 permanent_location => 'permanent_location',
1706 onloan => 'checked_out_date',
1707 cn_source => 'call_number_source',
1708 cn_sort => 'call_number_sort',
1709 ccode => 'collection_code',
1710 materials => 'materials_notes',
1712 itype => 'item_type_id',
1713 more_subfields_xml => 'extended_subfields',
1714 enumchron => 'serial_issue_number',
1715 copynumber => 'copy_number',
1716 stocknumber => 'inventory_number',
1717 new_status => 'new_status',
1718 deleted_on => undef,
1724 my $itemtype = $item->itemtype;
1726 Returns Koha object for effective itemtype
1733 return Koha::ItemTypes->find( $self->effective_itemtype );
1738 my $orders = $item->orders();
1740 Returns a Koha::Acquisition::Orders object
1747 my $orders = $self->_result->item_orders;
1748 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1751 =head3 tracked_links
1753 my $tracked_links = $item->tracked_links();
1755 Returns a Koha::TrackedLinks object
1762 my $tracked_links = $self->_result->linktrackers;
1763 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1766 =head3 move_to_biblio
1768 $item->move_to_biblio($to_biblio[, $params]);
1770 Move the item to another biblio and update any references in other tables.
1772 The final optional parameter, C<$params>, is expected to contain the
1773 'skip_record_index' key, which is relayed down to Koha::Item->store.
1774 There it prevents calling index_records, which takes most of the
1775 time in batch adds/deletes. The caller must take care of calling
1776 index_records separately.
1779 skip_record_index => 1|0
1781 Returns undef if the move failed or the biblionumber of the destination record otherwise
1785 sub move_to_biblio {
1786 my ( $self, $to_biblio, $params ) = @_;
1790 return if $self->biblionumber == $to_biblio->biblionumber;
1792 my $from_biblionumber = $self->biblionumber;
1793 my $to_biblionumber = $to_biblio->biblionumber;
1795 # Own biblionumber and biblioitemnumber
1797 biblionumber => $to_biblionumber,
1798 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1799 })->store({ skip_record_index => $params->{skip_record_index} });
1801 unless ($params->{skip_record_index}) {
1802 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1803 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1806 # Acquisition orders
1807 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1810 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1812 # hold_fill_target (there's no Koha object available yet)
1813 my $hold_fill_target = $self->_result->hold_fill_target;
1814 if ($hold_fill_target) {
1815 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1818 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1819 # and can't even fake one since the significant columns are nullable.
1820 my $storage = $self->_result->result_source->storage;
1823 my ($storage, $dbh, @cols) = @_;
1825 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1830 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1832 return $to_biblionumber;
1837 my $bundle_items = $item->bundle_items;
1839 Returns the items associated with this bundle
1846 my $rs = $self->_result->bundle_items;
1847 return Koha::Items->_new_from_dbic($rs);
1852 my $is_bundle = $item->is_bundle;
1854 Returns whether the item is a bundle or not
1860 return $self->bundle_items->count ? 1 : 0;
1865 my $bundle = $item->bundle_host;
1867 Returns the bundle item this item is attached to
1874 my $bundle_items_rs = $self->_result->item_bundles_item;
1875 return unless $bundle_items_rs;
1876 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1881 my $in_bundle = $item->in_bundle;
1883 Returns whether this item is currently in a bundle
1889 return $self->bundle_host ? 1 : 0;
1892 =head3 add_to_bundle
1894 my $link = $item->add_to_bundle($bundle_item);
1896 Adds the bundle_item passed to this item
1901 my ( $self, $bundle_item, $options ) = @_;
1905 Koha::Exceptions::Item::Bundle::IsBundle->throw()
1906 if ( $self->itemnumber eq $bundle_item->itemnumber
1907 || $bundle_item->is_bundle
1908 || $self->in_bundle );
1910 my $schema = Koha::Database->new->schema;
1912 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1918 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
1920 my $checkout = $bundle_item->checkout;
1922 unless ($options->{force_checkin}) {
1923 Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
1926 my $branchcode = C4::Context->userenv->{'branch'};
1927 my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
1929 Koha::Exceptions::Checkin::FailedCheckin->throw();
1933 my $holds = $bundle_item->current_holds;
1934 if ($holds->count) {
1935 unless ($options->{ignore_holds}) {
1936 Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
1940 $self->_result->add_to_item_bundles_hosts(
1941 { item => $bundle_item->itemnumber } );
1943 $bundle_item->notforloan($BundleNotLoanValue)->store();
1949 # 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
1950 if ( ref($_) eq 'DBIx::Class::Exception' ) {
1951 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1953 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1954 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1955 Koha::Exceptions::Object::FKConstraint->throw(
1956 error => 'Broken FK constraint',
1957 broken_fk => $+{column}
1962 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
1964 Koha::Exceptions::Object::DuplicateID->throw(
1965 error => 'Duplicate ID',
1966 duplicate_id => $+{key}
1969 elsif ( $_->{msg} =~
1970 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
1972 { # The optional \W in the regex might be a quote or backtick
1973 my $type = $+{type};
1974 my $value = $+{value};
1975 my $property = $+{property};
1976 $property =~ s/['`]//g;
1977 Koha::Exceptions::Object::BadValue->throw(
1980 property => $property =~ /(\w+\.\w+)$/
1983 , # results in table.column without quotes or backtics
1987 # Catch-all for foreign key breakages. It will help find other use cases
1996 =head3 remove_from_bundle
1998 Remove this item from any bundle it may have been attached to.
2002 sub remove_from_bundle {
2005 my $bundle_host = $self->bundle_host;
2007 return 0 unless $bundle_host; # Should not we raise an exception here?
2009 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $bundle_host->checkout;
2011 my $bundle_item_rs = $self->_result->item_bundles_item;
2012 if ( $bundle_item_rs ) {
2013 $bundle_item_rs->delete;
2014 $self->notforloan(0)->store();
2020 =head2 Internal methods
2022 =head3 _after_item_action_hooks
2024 Helper method that takes care of calling all plugin hooks
2028 sub _after_item_action_hooks {
2029 my ( $self, $params ) = @_;
2031 my $action = $params->{action};
2033 Koha::Plugins->call(
2034 'after_item_action',
2038 item_id => $self->itemnumber,
2045 my $recall = $item->recall;
2047 Return the relevant recall for this item
2053 my @recalls = Koha::Recalls->search(
2055 biblio_id => $self->biblionumber,
2058 { order_by => { -asc => 'created_date' } }
2061 my $item_level_recall;
2062 foreach my $recall (@recalls) {
2063 if ( $recall->item_level ) {
2064 $item_level_recall = 1;
2065 if ( $recall->item_id == $self->itemnumber ) {
2070 if ($item_level_recall) {
2072 # recall needs to be filled be a specific item only
2073 # no other item is relevant to return
2077 # no item-level recall to return, so return earliest biblio-level
2078 # FIXME: eventually this will be based on priority
2082 =head3 can_be_recalled
2084 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
2086 Does item-level checks and returns if items can be recalled by this borrower
2090 sub can_be_recalled {
2091 my ( $self, $params ) = @_;
2093 return 0 if !( C4::Context->preference('UseRecalls') );
2095 # check if this item is not for loan, withdrawn or lost
2096 return 0 if ( $self->notforloan != 0 );
2097 return 0 if ( $self->itemlost != 0 );
2098 return 0 if ( $self->withdrawn != 0 );
2100 # check if this item is not checked out - if not checked out, can't be recalled
2101 return 0 if ( !defined( $self->checkout ) );
2103 my $patron = $params->{patron};
2105 my $branchcode = C4::Context->userenv->{'branch'};
2107 $branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
2110 # Check the circulation rule for each relevant itemtype for this item
2111 my $rule = Koha::CirculationRules->get_effective_rules({
2112 branchcode => $branchcode,
2113 categorycode => $patron ? $patron->categorycode : undef,
2114 itemtype => $self->effective_itemtype,
2117 'recalls_per_record',
2122 # check recalls allowed has been set and is not zero
2123 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2126 # check borrower has not reached open recalls allowed limit
2127 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
2129 # check borrower has not reach open recalls allowed per record limit
2130 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
2132 # check if this patron has already recalled this item
2133 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
2135 # check if this patron has already checked out this item
2136 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2138 # check if this patron has already reserved this item
2139 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2142 # check item availability
2143 # items are unavailable for recall if they are lost, withdrawn or notforloan
2144 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
2146 # if there are no available items at all, no recall can be placed
2147 return 0 if ( scalar @items == 0 );
2149 my $checked_out_count = 0;
2151 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
2154 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
2155 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
2157 # can't recall if no items have been checked out
2158 return 0 if ( $checked_out_count == 0 );
2164 =head3 can_be_waiting_recall
2166 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
2168 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
2169 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
2173 sub can_be_waiting_recall {
2176 return 0 if !( C4::Context->preference('UseRecalls') );
2178 # check if this item is not for loan, withdrawn or lost
2179 return 0 if ( $self->notforloan != 0 );
2180 return 0 if ( $self->itemlost != 0 );
2181 return 0 if ( $self->withdrawn != 0 );
2183 my $branchcode = $self->holdingbranch;
2184 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
2185 $branchcode = C4::Context->userenv->{'branch'};
2187 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
2190 # Check the circulation rule for each relevant itemtype for this item
2191 my $most_relevant_recall = $self->check_recalls;
2192 my $rule = Koha::CirculationRules->get_effective_rules(
2194 branchcode => $branchcode,
2195 categorycode => $most_relevant_recall ? $most_relevant_recall->patron->categorycode : undef,
2196 itemtype => $self->effective_itemtype,
2197 rules => [ 'recalls_allowed', ],
2201 # check recalls allowed has been set and is not zero
2202 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2208 =head3 check_recalls
2210 my $recall = $item->check_recalls;
2212 Get the most relevant recall for this item.
2219 my @recalls = Koha::Recalls->search(
2220 { biblio_id => $self->biblionumber,
2221 item_id => [ $self->itemnumber, undef ]
2223 { order_by => { -asc => 'created_date' } }
2224 )->filter_by_current->as_list;
2227 # iterate through relevant recalls to find the best one.
2228 # if we come across a waiting recall, use this one.
2229 # 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.
2230 foreach my $r ( @recalls ) {
2231 if ( $r->waiting ) {
2236 unless ( defined $recall ) {
2237 $recall = $recalls[0];
2243 =head3 is_notforloan
2245 my $is_notforloan = $item->is_notforloan;
2247 Determine whether or not this item is "notforloan" based on
2248 the item's notforloan status or its item type
2254 my $is_notforloan = 0;
2256 if ( $self->notforloan ){
2260 my $itemtype = $self->itemtype;
2262 if ( $itemtype->notforloan ){
2268 return $is_notforloan;
2271 =head3 is_denied_renewal
2273 my $is_denied_renewal = $item->is_denied_renewal;
2275 Determine whether or not this item can be renewed based on the
2276 rules set in the ItemsDeniedRenewal system preference.
2280 sub is_denied_renewal {
2282 my $denyingrules = C4::Context->yaml_preference('ItemsDeniedRenewal');
2283 return 0 unless $denyingrules;
2284 foreach my $field (keys %$denyingrules) {
2285 # Silently ignore bad column names; TODO we should validate elsewhere
2286 next if !$self->_result->result_source->has_column($field);
2287 my $val = $self->$field;
2288 if( !defined $val) {
2289 if ( any { !defined $_ } @{$denyingrules->{$field}} ){
2292 } elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
2293 # If the results matches the values in the syspref
2294 # We return true if match found
2303 Returns a map of column name to string representations including the string,
2304 the mapping type and the mapping category where appropriate.
2306 Currently handles authorised value mappings, library, callnumber and itemtype
2309 Accepts a param hashref where the 'public' key denotes whether we want the public
2310 or staff client strings.
2315 my ( $self, $params ) = @_;
2316 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
2317 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
2318 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
2320 my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
2322 # Hardcoded known 'authorised_value' values mapped to API codes
2323 my $code_to_type = {
2324 branches => 'library',
2325 cn_source => 'call_number_source',
2326 itemtypes => 'item_type',
2329 # Handle not null and default values for integers and dates
2332 foreach my $col ( @{$self->_columns} ) {
2334 # By now, we are done with known columns, now check the framework for mappings
2335 my $field = $self->_result->result_source->name . '.' . $col;
2337 # Check there's an entry in the MARC subfield structure for the field
2338 if ( exists $mss->{$field}
2339 && scalar @{ $mss->{$field} } > 0
2340 && $mss->{$field}[0]->{authorised_value} )
2342 my $subfield = $mss->{$field}[0];
2343 my $code = $subfield->{authorised_value};
2345 my $str = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
2346 my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
2347 $strings->{$col} = {
2350 ( $type eq 'av' ? ( category => $code ) : () ),
2359 =head3 location_update_trigger
2361 $item->location_update_trigger( $action );
2363 Updates the item location based on I<$action>. It is done like this:
2367 =item For B<checkin>, location is updated following the I<UpdateItemLocationOnCheckin> preference.
2369 =item For B<checkout>, location is updated following the I<UpdateItemLocationOnCheckout> preference.
2373 FIXME: It should return I<$self>. See bug 35270.
2377 sub location_update_trigger {
2378 my ( $self, $action ) = @_;
2380 my ( $update_loc_rules, $messages );
2381 if ( $action eq 'checkin' ) {
2382 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckin');
2384 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckout');
2387 if ($update_loc_rules) {
2388 if ( defined $update_loc_rules->{_ALL_} ) {
2389 if ( $update_loc_rules->{_ALL_} eq '_PERM_' ) {
2390 $update_loc_rules->{_ALL_} = $self->permanent_location;
2392 if ( $update_loc_rules->{_ALL_} eq '_BLANK_' ) {
2393 $update_loc_rules->{_ALL_} = '';
2396 ( defined $self->location && $self->location ne $update_loc_rules->{_ALL_} )
2397 || ( !defined $self->location
2398 && $update_loc_rules->{_ALL_} ne "" )
2401 $messages->{'ItemLocationUpdated'} =
2402 { from => $self->location, to => $update_loc_rules->{_ALL_} };
2403 $self->location( $update_loc_rules->{_ALL_} )->store(
2406 skip_record_index => 1,
2407 skip_holds_queue => 1
2412 foreach my $key ( keys %$update_loc_rules ) {
2413 if ( $update_loc_rules->{$key} eq '_PERM_' ) {
2414 $update_loc_rules->{$key} = $self->permanent_location;
2415 } elsif ( $update_loc_rules->{$key} eq '_BLANK_' ) {
2416 $update_loc_rules->{$key} = '';
2420 defined $self->location
2421 && $self->location eq $key
2422 && $self->location ne $update_loc_rules->{$key}
2424 || ( $key eq '_BLANK_'
2425 && ( !defined $self->location || $self->location eq '' )
2426 && $update_loc_rules->{$key} ne '' )
2429 $messages->{'ItemLocationUpdated'} = {
2430 from => $self->location,
2431 to => $update_loc_rules->{$key}
2433 $self->location( $update_loc_rules->{$key} )->store(
2436 skip_record_index => 1,
2437 skip_holds_queue => 1
2458 Kyle M Hall <kyle@bywatersolutions.com>