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;
39 use Koha::Course::Items;
40 use Koha::CoverImages;
42 use Koha::Exceptions::Checkin;
43 use Koha::Exceptions::Item::Bundle;
44 use Koha::Exceptions::Item::Transfer;
45 use Koha::Item::Attributes;
46 use Koha::Exceptions::Item::Bundle;
47 use Koha::Item::Transfer::Limits;
48 use Koha::Item::Transfers;
54 use Koha::Result::Boolean;
55 use Koha::SearchEngine::Indexer;
56 use Koha::Serial::Items;
57 use Koha::StockRotationItem;
58 use Koha::StockRotationRotas;
59 use Koha::TrackedLinks;
60 use Koha::Policy::Holds;
62 use base qw(Koha::Object);
66 Koha::Item - Koha Item object class
78 $params can take an optional 'skip_record_index' parameter.
79 If set, the reindexation process will not happen (index_records not called)
80 You should not turn it on if you do not understand what it is doing exactly.
86 my $params = @_ ? shift : {};
88 my $log_action = $params->{log_action} // 1;
90 # We do not want to oblige callers to pass this value
91 # Dev conveniences vs performance?
92 unless ( $self->biblioitemnumber ) {
93 $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
96 # See related changes from C4::Items::AddItem
97 unless ( $self->itype ) {
98 $self->itype($self->biblio->biblioitem->itemtype);
101 $self->barcode( C4::Circulation::barcodedecode( $self->barcode ) );
103 my $today = dt_from_string;
104 my $action = 'create';
106 unless ( $self->in_storage ) { #AddItem
108 unless ( $self->permanent_location ) {
109 $self->permanent_location($self->location);
112 my $default_location = C4::Context->preference('NewItemsDefaultLocation');
113 unless ( $self->location || !$default_location ) {
114 $self->permanent_location( $self->location || $default_location )
115 unless $self->permanent_location;
116 $self->location($default_location);
119 unless ( $self->replacementpricedate ) {
120 $self->replacementpricedate($today);
122 unless ( $self->datelastseen ) {
123 $self->datelastseen($today);
126 unless ( $self->dateaccessioned ) {
127 $self->dateaccessioned($today);
130 if ( $self->itemcallnumber
131 or $self->cn_source )
133 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
134 $self->cn_sort($cn_sort);
137 # should be quite rare when adding item
138 if ( $self->itemlost && $self->itemlost > 0 ) { # TODO BZ34308
139 $self->_add_statistic('item_lost');
146 my %updated_columns = $self->_result->get_dirty_columns;
147 return $self->SUPER::store unless %updated_columns;
149 # Retrieve the item for comparison if we need to
151 exists $updated_columns{itemlost}
152 or exists $updated_columns{withdrawn}
153 or exists $updated_columns{damaged}
154 ) ? $self->get_from_storage : undef;
156 # Update *_on fields if needed
157 # FIXME: Why not for AddItem as well?
158 my @fields = qw( itemlost withdrawn damaged );
159 for my $field (@fields) {
161 # If the field is defined but empty or 0, we are
162 # removing/unsetting and thus need to clear out
164 if ( exists $updated_columns{$field}
165 && defined( $self->$field )
168 my $field_on = "${field}_on";
169 $self->$field_on(undef);
171 # If the field has changed otherwise, we much update
173 elsif (exists $updated_columns{$field}
174 && $updated_columns{$field}
175 && !$pre_mod_item->$field )
177 my $field_on = "${field}_on";
178 $self->$field_on(dt_from_string);
182 if ( exists $updated_columns{itemcallnumber}
183 or exists $updated_columns{cn_source} )
185 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
186 $self->cn_sort($cn_sort);
190 if ( exists $updated_columns{location}
191 and ( !defined($self->location) or $self->location !~ /^(CART|PROC)$/ )
192 and not exists $updated_columns{permanent_location} )
194 $self->permanent_location( $self->location );
197 # TODO BZ 34308 (gt zero checks)
198 if ( exists $updated_columns{itemlost}
199 && ( !$updated_columns{itemlost} || $updated_columns{itemlost} <= 0 )
200 && ( $pre_mod_item->itemlost && $pre_mod_item->itemlost > 0 ) )
203 # reverse any list item charges if necessary
204 $self->_set_found_trigger($pre_mod_item);
205 $self->_add_statistic('item_found');
206 } elsif ( exists $updated_columns{itemlost}
207 && ( $updated_columns{itemlost} && $updated_columns{itemlost} > 0 )
208 && ( !$pre_mod_item->itemlost || $pre_mod_item->itemlost <= 0 ) )
211 $self->_add_statistic('item_lost');
215 my $result = $self->SUPER::store;
216 if ( $log_action && C4::Context->preference("CataloguingLog") ) {
218 ? logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
219 : logaction( "CATALOGUING", "MODIFY", $self->itemnumber, $self );
221 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
222 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
223 unless $params->{skip_record_index};
224 $self->get_from_storage->_after_item_action_hooks({ action => $action });
226 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
228 biblio_ids => [ $self->biblionumber ]
230 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
236 my ( $self, $type ) = @_;
237 C4::Stats::UpdateStats(
239 borrowernumber => undef,
240 branch => C4::Context->userenv ? C4::Context->userenv->{branch} : undef,
241 categorycode => undef,
242 ccode => $self->ccode,
243 itemnumber => $self->itemnumber,
244 itemtype => $self->effective_itemtype,
245 location => $self->location,
257 my $params = @_ ? shift : {};
259 # FIXME check the item has no current issues
260 # i.e. raise the appropriate exception
262 # Get the item group so we can delete it later if it has no items left
263 my $item_group = C4::Context->preference('EnableItemGroups') ? $self->item_group : undef;
265 my $result = $self->SUPER::delete;
267 # Delete the item group if it has no items left
268 $item_group->delete if ( $item_group && $item_group->items->count == 0 );
270 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
271 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
272 unless $params->{skip_record_index};
274 $self->_after_item_action_hooks({ action => 'delete' });
276 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
277 if C4::Context->preference("CataloguingLog");
279 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
281 biblio_ids => [ $self->biblionumber ]
283 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
294 my $params = @_ ? shift : {};
296 my $safe_to_delete = $self->safe_to_delete;
297 return $safe_to_delete unless $safe_to_delete;
299 $self->move_to_deleted;
301 return $self->delete($params);
304 =head3 safe_to_delete
306 returns 1 if the item is safe to delete,
308 "book_on_loan" if the item is checked out,
310 "not_same_branch" if the item is blocked by independent branches,
312 "book_reserved" if the there are holds aganst the item, or
314 "linked_analytics" if the item has linked analytic records.
316 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
325 $error = "book_on_loan" if $self->checkout;
327 $error //= "not_same_branch"
328 if defined C4::Context->userenv
329 and defined C4::Context->userenv->{number}
330 and !Koha::Patrons->find( C4::Context->userenv->{number} )->can_edit_items_from( $self->homebranch );
332 # check it doesn't have a waiting reserve
333 $error //= "book_reserved"
334 if $self->holds->filter_by_found->count;
336 $error //= "linked_analytics"
337 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
339 $error //= "last_item_for_hold"
340 if $self->biblio->items->count == 1
341 && $self->biblio->holds->search(
348 return Koha::Result::Boolean->new(0)->add_message({ message => $error });
351 return Koha::Result::Boolean->new(1);
354 =head3 move_to_deleted
356 my $is_moved = $item->move_to_deleted;
358 Move an item to the deleteditems table.
359 This can be done before deleting an item, to make sure the data are not completely deleted.
363 sub move_to_deleted {
365 my $item_infos = $self->unblessed;
366 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
367 $item_infos->{deleted_on} = dt_from_string;
368 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
371 =head3 effective_itemtype
373 Returns the itemtype for the item based on whether item level itemtypes are set or not.
377 sub effective_itemtype {
380 return $self->_result()->effective_itemtype();
390 my $hb_rs = $self->_result->homebranch;
392 return Koha::Library->_new_from_dbic( $hb_rs );
395 =head3 holding_branch
402 my $hb_rs = $self->_result->holdingbranch;
404 return Koha::Library->_new_from_dbic( $hb_rs );
409 my $biblio = $item->biblio;
411 Return the bibliographic record of this item
417 my $biblio_rs = $self->_result->biblio;
418 return Koha::Biblio->_new_from_dbic( $biblio_rs );
423 my $biblioitem = $item->biblioitem;
425 Return the biblioitem record of this item
431 my $biblioitem_rs = $self->_result->biblioitem;
432 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
437 my $checkout = $item->checkout;
439 Return the checkout for this item
445 my $checkout_rs = $self->_result->issue;
446 return unless $checkout_rs;
447 return Koha::Checkout->_new_from_dbic( $checkout_rs );
456 my $rs = $self->_result->serialitem;
458 return Koha::Serial::Item->_new_from_dbic($rs);
463 my $item_group = $item->item_group;
465 Return the item group for this item
472 my $item_group_item = $self->_result->item_group_item;
473 return unless $item_group_item;
475 my $item_group_rs = $item_group_item->item_group;
476 return unless $item_group_rs;
478 my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs );
482 sub item_group_item {
484 my $rs = $self->_result->item_group_item;
486 return Koha::Biblio::ItemGroup::Item->_new_from_dbic($rs);
492 my $return_claims = $item->return_claims;
494 Return any return_claims associated with this item
499 my ( $self, $params, $attrs ) = @_;
500 my $claims_rs = $self->_result->return_claims->search($params, $attrs);
501 return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs );
506 my $return_claim = $item->return_claim;
508 Returns the most recent unresolved return_claims associated with this item
515 $self->_result->return_claims->search( { resolution => undef },
516 { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single;
517 return unless $claims_rs;
518 return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs);
523 my $holds = $item->holds();
524 my $holds = $item->holds($params);
525 my $holds = $item->holds({ found => 'W'});
527 Return holds attached to an item, optionally accept a hashref of params to pass to search
532 my ( $self,$params ) = @_;
533 my $holds_rs = $self->_result->reserves->search($params);
534 return Koha::Holds->_new_from_dbic( $holds_rs );
539 my $bookings = $item->bookings();
541 Returns the bookings attached to this item.
546 my ( $self, $params ) = @_;
547 my $bookings_rs = $self->_result->bookings->search($params);
548 return Koha::Bookings->_new_from_dbic($bookings_rs);
553 my $booking = $item->find_booking( { checkout_date => $now, due_date => $future_date } );
555 Find the first booking that would conflict with the passed checkout dates for this item.
557 FIXME: This can be simplified, it was originally intended to iterate all biblio level bookings
558 to catch cases where this item may be the last available to satisfy a biblio level only booking.
559 However, we dropped the biblio level functionality prior to push as bugs were found in it's
565 my ( $self, $params ) = @_;
567 my $checkout_date = $params->{checkout_date};
568 my $due_date = $params->{due_date};
569 my $biblio = $self->biblio;
571 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
572 my $bookings = $biblio->bookings(
574 # Proposed checkout starts during booked period
577 $dtf->format_datetime($checkout_date),
578 $dtf->format_datetime($due_date)
582 # Proposed checkout is due during booked period
585 $dtf->format_datetime($checkout_date),
586 $dtf->format_datetime($due_date)
590 # Proposed checkout would contain the booked period
592 start_date => { '<' => $dtf->format_datetime($checkout_date) },
593 end_date => { '>' => $dtf->format_datetime($due_date) }
596 { order_by => { '-asc' => 'start_date' } }
600 my $loanable_items = {};
601 my $bookable_items = $biblio->bookable_items;
602 while ( my $item = $bookable_items->next ) {
603 $loanable_items->{ $item->itemnumber } = 1;
604 if ( my $checkout = $item->checkout ) {
605 $checkouts->{ $item->itemnumber } = dt_from_string( $checkout->date_due );
609 while ( my $booking = $bookings->next ) {
611 # Booking for this item
612 if ( defined( $booking->item_id )
613 && $booking->item_id == $self->itemnumber )
618 # Booking for another item
619 elsif ( defined( $booking->item_id ) ) {
620 # Due for another booking, remove from pool
621 delete $loanable_items->{ $booking->item_id };
626 # Booking for any item
628 # Can another item satisfy this booking?
637 $item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
639 Returns a boolean denoting whether the passed booking can be made without clashing.
641 Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
646 my ( $self, $params ) = @_;
648 my $start_date = dt_from_string( $params->{start_date} );
649 my $end_date = dt_from_string( $params->{end_date} );
650 my $booking_id = $params->{booking_id};
652 if ( my $checkout = $self->checkout ) {
653 return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
656 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
658 my $existing_bookings = $self->bookings(
662 $dtf->format_datetime($start_date),
663 $dtf->format_datetime($end_date)
668 $dtf->format_datetime($start_date),
669 $dtf->format_datetime($end_date)
673 start_date => { '<' => $dtf->format_datetime($start_date) },
674 end_date => { '>' => $dtf->format_datetime($end_date) }
681 ? $existing_bookings->search( { booking_id => { '!=' => $booking_id } } )->count
682 : $existing_bookings->count;
684 return $bookings_count ? 0 : 1;
687 =head3 request_transfer
689 my $transfer = $item->request_transfer(
693 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
697 Add a transfer request for this item to the given branch for the given reason.
699 An exception will be thrown if the BranchTransferLimits would prevent the requested
700 transfer, unless 'ignore_limits' is passed to override the limits.
702 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
703 The caller should catch such cases and retry the transfer request as appropriate passing
704 an appropriate override.
707 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
708 * replace - Used to replace the existing transfer request with your own.
712 sub request_transfer {
713 my ( $self, $params ) = @_;
715 # check for mandatory params
716 my @mandatory = ( 'to', 'reason' );
717 for my $param (@mandatory) {
718 unless ( defined( $params->{$param} ) ) {
719 Koha::Exceptions::MissingParameter->throw(
720 error => "The $param parameter is mandatory" );
724 Koha::Exceptions::Item::Transfer::Limit->throw()
725 unless ( $params->{ignore_limits}
726 || $self->can_be_transferred( { to => $params->{to} } ) );
728 my $request = $self->get_transfer;
729 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
730 if ( $request && !$params->{enqueue} && !$params->{replace} );
732 $request->cancel( { reason => $params->{reason}, force => 1 } )
733 if ( defined($request) && $params->{replace} );
735 my $transfer = Koha::Item::Transfer->new(
737 itemnumber => $self->itemnumber,
738 daterequested => dt_from_string,
739 frombranch => $self->holdingbranch,
740 tobranch => $params->{to}->branchcode,
741 reason => $params->{reason},
742 comments => $params->{comment}
751 my $transfer = $item->get_transfer;
753 Return the active transfer request or undef
755 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
756 whereby the most recently sent, but not received, transfer will be returned
757 if it exists, otherwise the oldest unsatisfied transfer will be returned.
759 This allows for transfers to queue, which is the case for stock rotation and
760 rotating collections where a manual transfer may need to take precedence but
761 we still expect the item to end up at a final location eventually.
768 my $transfer = $self->_result->current_branchtransfers->next;
769 return Koha::Item::Transfer->_new_from_dbic($transfer) if $transfer;
773 return shift->get_transfer(@_);
778 my $transfer = $item->get_transfers;
780 Return the list of outstanding transfers (i.e requested but not yet cancelled
783 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
784 whereby the most recently sent, but not received, transfer will be returned
785 first if it exists, otherwise requests are in oldest to newest request order.
787 This allows for transfers to queue, which is the case for stock rotation and
788 rotating collections where a manual transfer may need to take precedence but
789 we still expect the item to end up at a final location eventually.
796 my $transfer_rs = $self->_result->current_branchtransfers;
798 return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
801 =head3 last_returned_by
803 Gets and sets the last patron to return an item.
805 Accepts a patron's id (borrowernumber) and returns Koha::Patron objects
807 $item->last_returned_by( $borrowernumber );
809 my $patron = $item->last_returned_by();
813 sub last_returned_by {
814 my ( $self, $borrowernumber ) = @_;
815 if ( $borrowernumber ) {
816 $self->_result->update_or_create_related('last_returned_by',
817 { borrowernumber => $borrowernumber, itemnumber => $self->itemnumber } );
819 my $rs = $self->_result->last_returned_by;
821 return Koha::Patron->_new_from_dbic($rs->borrowernumber);
824 =head3 can_article_request
826 my $bool = $item->can_article_request( $borrower )
828 Returns true if item can be specifically requested
830 $borrower must be a Koha::Patron object
834 sub can_article_request {
835 my ( $self, $borrower ) = @_;
837 my $rule = $self->article_request_type($borrower);
839 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
843 =head3 hidden_in_opac
845 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
847 Returns true if item fields match the hidding criteria defined in $rules.
848 Returns false otherwise.
850 Takes HASHref that can have the following parameters:
852 $rules : { <field> => [ value_1, ... ], ... }
854 Note: $rules inherits its structure from the parsed YAML from reading
855 the I<OpacHiddenItems> system preference.
860 my ( $self, $params ) = @_;
862 my $rules = $params->{rules} // {};
865 if C4::Context->preference('hidelostitems') and
868 my $hidden_in_opac = 0;
870 foreach my $field ( keys %{$rules} ) {
872 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
878 return $hidden_in_opac;
881 =head3 can_be_transferred
883 $item->can_be_transferred({ to => $to_library, from => $from_library })
884 Checks if an item can be transferred to given library.
886 This feature is controlled by two system preferences:
887 UseBranchTransferLimits to enable / disable the feature
888 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
889 for setting the limitations
891 Takes HASHref that can have the following parameters:
892 MANDATORY PARAMETERS:
895 $from : Koha::Library # if not given, item holdingbranch
896 # will be used instead
898 Returns 1 if item can be transferred to $to_library, otherwise 0.
900 To find out whether at least one item of a Koha::Biblio can be transferred, please
901 see Koha::Biblio->can_be_transferred() instead of using this method for
902 multiple items of the same biblio.
906 sub can_be_transferred {
907 my ($self, $params) = @_;
909 my $to = $params->{to};
910 my $from = $params->{from};
912 $to = $to->branchcode;
913 $from = defined $from ? $from->branchcode : $self->holdingbranch;
915 return 1 if $from eq $to; # Transfer to current branch is allowed
916 return 1 unless C4::Context->preference('UseBranchTransferLimits');
918 my $limittype = C4::Context->preference('BranchTransferLimitsType');
919 return Koha::Item::Transfer::Limits->search({
922 $limittype => $limittype eq 'itemtype'
923 ? $self->effective_itemtype : $self->ccode
928 =head3 pickup_locations
930 my $pickup_locations = $item->pickup_locations({ patron => $patron })
932 Returns possible pickup locations for this item, according to patron's home library
933 and if item can be transferred to each pickup location.
935 Throws a I<Koha::Exceptions::MissingParameter> exception if the B<mandatory> parameter I<patron>
940 sub pickup_locations {
941 my ($self, $params) = @_;
943 Koha::Exceptions::MissingParameter->throw( parameter => 'patron' )
944 unless exists $params->{patron};
946 my $patron = $params->{patron};
948 my $circ_control_branch = Koha::Policy::Holds->holds_control_library( $self, $patron );
950 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
952 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
953 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
955 my $pickup_libraries = Koha::Libraries->search();
956 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
957 $pickup_libraries = $self->home_branch->get_hold_libraries;
958 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
959 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
960 $pickup_libraries = $plib->get_hold_libraries;
961 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
962 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
963 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
964 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
967 return $pickup_libraries->search(
972 order_by => ['branchname']
974 ) unless C4::Context->preference('UseBranchTransferLimits');
976 my $limittype = C4::Context->preference('BranchTransferLimitsType');
977 my ($ccode, $itype) = (undef, undef);
978 if( $limittype eq 'ccode' ){
979 $ccode = $self->ccode;
981 $itype = $self->itype;
983 my $limits = Koha::Item::Transfer::Limits->search(
985 fromBranch => $self->holdingbranch,
989 { columns => ['toBranch'] }
992 return $pickup_libraries->search(
994 pickup_location => 1,
996 '-not_in' => $limits->_resultset->as_query
1000 order_by => ['branchname']
1005 =head3 article_request_type
1007 my $type = $item->article_request_type( $borrower )
1009 returns 'yes', 'no', 'bib_only', or 'item_only'
1011 $borrower must be a Koha::Patron object
1015 sub article_request_type {
1016 my ( $self, $borrower ) = @_;
1018 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
1020 $branch_control eq 'homebranch' ? $self->homebranch
1021 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
1023 my $borrowertype = $borrower->categorycode;
1024 my $itemtype = $self->effective_itemtype();
1025 my $rule = Koha::CirculationRules->get_effective_rule(
1027 rule_name => 'article_requests',
1028 categorycode => $borrowertype,
1029 itemtype => $itemtype,
1030 branchcode => $branchcode
1034 return q{} unless $rule;
1035 return $rule->rule_value || q{}
1038 =head3 current_holds
1044 my $attributes = { order_by => 'priority' };
1045 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1047 itemnumber => $self->itemnumber,
1050 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
1051 waitingdate => { '!=' => undef },
1054 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
1055 return Koha::Holds->_new_from_dbic($hold_rs);
1060 return $self->current_holds->next;
1063 =head3 stockrotationitem
1065 my $sritem = Koha::Item->stockrotationitem;
1067 Returns the stock rotation item associated with the current item.
1071 sub stockrotationitem {
1073 my $rs = $self->_result->stockrotationitem;
1075 return Koha::StockRotationItem->_new_from_dbic( $rs );
1080 my $item = $item->add_to_rota($rota_id);
1082 Add this item to the rota identified by $ROTA_ID, which means associating it
1083 with the first stage of that rota. Should this item already be associated
1084 with a rota, then we will move it to the new rota.
1089 my ( $self, $rota_id ) = @_;
1090 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
1094 =head3 has_pending_hold
1096 my $is_pending_hold = $item->has_pending_hold();
1098 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
1102 sub has_pending_hold {
1104 return $self->_result->tmp_holdsqueue ? 1 : 0;
1107 =head3 has_pending_recall {
1109 my $has_pending_recall
1111 Return if whether has pending recall of not.
1115 sub has_pending_recall {
1118 # FIXME Must be moved to $self->recalls
1119 return Koha::Recalls->search(
1121 item_id => $self->itemnumber,
1122 status => 'waiting',
1127 =head3 as_marc_field
1129 my $field = $item->as_marc_field;
1131 This method returns a MARC::Field object representing the Koha::Item object
1132 with the current mappings configuration.
1139 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1141 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
1145 my $item_field = $tagslib->{$itemtag};
1147 my $more_subfields = $self->additional_attributes->to_hashref;
1148 foreach my $subfield (
1150 $a->{display_order} <=> $b->{display_order}
1151 || $a->{subfield} cmp $b->{subfield}
1152 } grep { ref($_) && %$_ } values %$item_field
1155 my $kohafield = $subfield->{kohafield};
1156 my $tagsubfield = $subfield->{tagsubfield};
1158 if ( defined $kohafield && $kohafield ne '' ) {
1159 next if $kohafield !~ m{^items\.}; # That would be weird!
1160 ( my $attribute = $kohafield ) =~ s|^items\.||;
1161 $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
1162 if defined $self->$attribute and $self->$attribute ne '';
1164 $value = $more_subfields->{$tagsubfield}
1167 next unless defined $value
1170 if ( $subfield->{repeatable} ) {
1171 my @values = split '\|', $value;
1172 push @subfields, ( $tagsubfield => $_ ) for @values;
1175 push @subfields, ( $tagsubfield => $value );
1180 return unless @subfields;
1182 return MARC::Field->new(
1183 "$itemtag", ' ', ' ', @subfields
1187 =head3 renewal_branchcode
1189 Returns the branchcode to be recorded in statistics renewal of the item
1193 sub renewal_branchcode {
1195 my ( $self, $params ) = @_;
1197 my $interface = C4::Context->interface;
1199 my $renewal_branchcode;
1201 if ( $interface eq 'opac' ) {
1202 $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1203 if ( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ) {
1204 $branchcode = 'OPACRenew';
1206 } elsif ( $interface eq 'api' ) {
1207 $renewal_branchcode = C4::Context->preference('RESTAPIRenewalBranch');
1208 if ( !defined $renewal_branchcode || $renewal_branchcode eq 'apirenew' ) {
1209 $branchcode = 'APIRenew';
1213 return $branchcode if $branchcode;
1215 if ($renewal_branchcode) {
1216 if ( $renewal_branchcode eq 'itemhomebranch' ) {
1217 $branchcode = $self->homebranch;
1218 } elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1219 $branchcode = $self->checkout->patron->branchcode;
1220 } elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1221 $branchcode = $self->checkout->branchcode;
1222 } elsif ( $renewal_branchcode eq 'apiuserbranch' ) {
1224 ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1225 ? C4::Context->userenv->{branch}
1226 : $params->{branch};
1232 ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1233 ? C4::Context->userenv->{branch}
1234 : $params->{branch};
1241 Return the cover images associated with this item.
1248 my $cover_images_rs = $self->_result->cover_images;
1249 return Koha::CoverImages->_new_from_dbic($cover_images_rs);
1252 =head3 cover_image_ids
1254 Return the cover image ids associated with this item.
1258 sub cover_image_ids {
1260 return [ $self->cover_images->get_column('imagenumber') ];
1263 =head3 columns_to_str
1265 my $values = $items->columns_to_str;
1267 Return a hashref with the string representation of the different attribute of the item.
1269 This is meant to be used for display purpose only.
1273 sub columns_to_str {
1275 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
1276 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
1277 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1279 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1282 for my $column ( @{$self->_columns}) {
1284 next if $column eq 'more_subfields_xml';
1286 my $value = $self->$column;
1287 # 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
1289 if ( not defined $value or $value eq "" ) {
1290 $values->{$column} = $value;
1295 exists $mss->{"items.$column"}
1296 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1299 $values->{$column} =
1301 ? $subfield->{authorised_value}
1302 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1303 $subfield->{tagsubfield}, $value, '', $tagslib )
1309 $self->more_subfields_xml
1310 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1315 my ( $field ) = $marc_more->fields;
1316 for my $sf ( $field->subfields ) {
1317 my $subfield_code = $sf->[0];
1318 my $value = $sf->[1];
1319 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1320 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1322 $subfield->{authorised_value}
1323 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1324 $subfield->{tagsubfield}, $value, '', $tagslib )
1327 push @{$more_values->{$subfield_code}}, $value;
1330 while ( my ( $k, $v ) = each %$more_values ) {
1331 $values->{$k} = join ' | ', @$v;
1338 =head3 additional_attributes
1340 my $attributes = $item->additional_attributes;
1341 $attributes->{k} = 'new k';
1342 $item->update({ more_subfields => $attributes->to_marcxml });
1344 Returns a Koha::Item::Attributes object that represents the non-mapped
1345 attributes for this item.
1349 sub additional_attributes {
1352 return Koha::Item::Attributes->new_from_marcxml(
1353 $self->more_subfields_xml,
1357 =head3 _set_found_trigger
1359 $self->_set_found_trigger
1361 Finds the most recent lost item charge for this item and refunds the patron
1362 appropriately, taking into account any payments or writeoffs already applied
1365 Internal function, not exported, called only by Koha::Item->store.
1369 sub _set_found_trigger {
1370 my ( $self, $pre_mod_item ) = @_;
1372 # Reverse any lost item charges if necessary.
1373 my $no_refund_after_days =
1374 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1375 if ($no_refund_after_days) {
1376 my $today = dt_from_string();
1377 my $lost_age_in_days =
1378 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1381 return $self unless $lost_age_in_days < $no_refund_after_days;
1384 my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
1387 return_branch => C4::Context->userenv
1388 ? C4::Context->userenv->{'branch'}
1392 my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
1394 if ( $lostreturn_policy ) {
1396 # refund charge made for lost book
1397 my $lost_charge = Koha::Account::Lines->search(
1399 itemnumber => $self->itemnumber,
1400 debit_type_code => 'LOST',
1401 status => [ undef, { '<>' => 'FOUND' } ]
1404 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1409 if ( $lost_charge ) {
1411 my $patron = $lost_charge->patron;
1414 my $account = $patron->account;
1416 # Credit outstanding amount
1417 my $credit_total = $lost_charge->amountoutstanding;
1421 $lost_charge->amount > $lost_charge->amountoutstanding &&
1422 $lostreturn_policy ne "refund_unpaid"
1424 # some amount has been cancelled. collect the offsets that are not writeoffs
1425 # this works because the only way to subtract from this kind of a debt is
1426 # using the UI buttons 'Pay' and 'Write off'
1428 # We don't credit any payments if return policy is
1431 # In that case only unpaid/outstanding amount
1432 # will be credited which settles the debt without
1433 # creating extra credits
1435 my $credit_offsets = $lost_charge->debit_offsets(
1437 'credit_id' => { '!=' => undef },
1438 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1440 { join => 'credit' }
1443 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1444 # credits are negative on the DB
1445 $credit_offsets->total * -1 :
1447 # Credit the outstanding amount, then add what has been
1448 # paid to create a net credit for this amount
1449 $credit_total += $total_to_refund;
1453 if ( $credit_total > 0 ) {
1455 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1456 $credit = $account->add_credit(
1458 amount => $credit_total,
1459 description => 'Item found ' . $self->itemnumber,
1460 type => 'LOST_FOUND',
1461 interface => C4::Context->interface,
1462 library_id => $branchcode,
1463 item_id => $self->itemnumber,
1464 issue_id => $lost_charge->issue_id
1468 $credit->apply( { debits => [$lost_charge] } );
1472 message => 'lost_refunded',
1473 payload => { credit_id => $credit->id }
1478 # Update the account status
1479 $lost_charge->status('FOUND');
1480 $lost_charge->store();
1482 # Reconcile balances if required
1483 if ( C4::Context->preference('AccountAutoReconcile') ) {
1484 $account->reconcile_balance;
1489 # possibly restore fine for lost book
1490 my $lost_overdue = Koha::Account::Lines->search(
1492 itemnumber => $self->itemnumber,
1493 debit_type_code => 'OVERDUE',
1497 order_by => { '-desc' => 'date' },
1501 if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
1503 my $patron = $lost_overdue->patron;
1505 my $account = $patron->account;
1507 # Update status of fine
1508 $lost_overdue->status('FOUND')->store();
1510 # Find related forgive credit
1511 my $refund = $lost_overdue->credits(
1513 credit_type_code => 'FORGIVEN',
1514 itemnumber => $self->itemnumber,
1515 status => [ { '!=' => 'VOID' }, undef ]
1517 { order_by => { '-desc' => 'date' }, rows => 1 }
1521 # Revert the forgive credit
1522 $refund->void({ interface => 'trigger' });
1526 message => 'lost_restored',
1527 payload => { refund_id => $refund->id }
1532 # Reconcile balances if required
1533 if ( C4::Context->preference('AccountAutoReconcile') ) {
1534 $account->reconcile_balance;
1538 } elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
1542 message => 'lost_charge',
1548 my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
1550 if ( $processingreturn_policy ) {
1552 # refund processing charge made for lost book
1553 my $processing_charge = Koha::Account::Lines->search(
1555 itemnumber => $self->itemnumber,
1556 debit_type_code => 'PROCESSING',
1557 status => [ undef, { '<>' => 'FOUND' } ]
1560 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1565 if ( $processing_charge ) {
1567 my $patron = $processing_charge->patron;
1570 my $account = $patron->account;
1572 # Credit outstanding amount
1573 my $credit_total = $processing_charge->amountoutstanding;
1577 $processing_charge->amount > $processing_charge->amountoutstanding &&
1578 $processingreturn_policy ne "refund_unpaid"
1580 # some amount has been cancelled. collect the offsets that are not writeoffs
1581 # this works because the only way to subtract from this kind of a debt is
1582 # using the UI buttons 'Pay' and 'Write off'
1584 # We don't credit any payments if return policy is
1587 # In that case only unpaid/outstanding amount
1588 # will be credited which settles the debt without
1589 # creating extra credits
1591 my $credit_offsets = $processing_charge->debit_offsets(
1593 'credit_id' => { '!=' => undef },
1594 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1596 { join => 'credit' }
1599 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1600 # credits are negative on the DB
1601 $credit_offsets->total * -1 :
1603 # Credit the outstanding amount, then add what has been
1604 # paid to create a net credit for this amount
1605 $credit_total += $total_to_refund;
1609 if ( $credit_total > 0 ) {
1611 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1612 $credit = $account->add_credit(
1614 amount => $credit_total,
1615 description => 'Item found ' . $self->itemnumber,
1616 type => 'PROCESSING_FOUND',
1617 interface => C4::Context->interface,
1618 library_id => $branchcode,
1619 item_id => $self->itemnumber,
1620 issue_id => $processing_charge->issue_id
1624 $credit->apply( { debits => [$processing_charge] } );
1628 message => 'processing_refunded',
1629 payload => { credit_id => $credit->id }
1634 # Update the account status
1635 $processing_charge->status('FOUND');
1636 $processing_charge->store();
1638 # Reconcile balances if required
1639 if ( C4::Context->preference('AccountAutoReconcile') ) {
1640 $account->reconcile_balance;
1649 =head3 public_read_list
1651 This method returns the list of publicly readable database fields for both API and UI output purposes
1655 sub public_read_list {
1657 'itemnumber', 'biblionumber', 'homebranch',
1658 'holdingbranch', 'location', 'collectioncode',
1659 'itemcallnumber', 'copynumber', 'enumchron',
1660 'barcode', 'dateaccessioned', 'itemnotes',
1661 'onloan', 'uri', 'itype',
1662 'notforloan', 'damaged', 'itemlost',
1663 'withdrawn', 'restricted'
1669 Overloaded to_api method to ensure item-level itypes is adhered to.
1674 my ($self, $params) = @_;
1676 my $response = $self->SUPER::to_api($params);
1679 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1681 my $itype_notforloan = $self->itemtype->notforloan;
1682 $overrides->{effective_not_for_loan_status} =
1683 ( defined $itype_notforloan && !$self->notforloan ) ? $itype_notforloan : $self->notforloan;
1685 return { %$response, %$overrides };
1688 =head3 to_api_mapping
1690 This method returns the mapping for representing a Koha::Item object
1695 sub to_api_mapping {
1697 itemnumber => 'item_id',
1698 biblionumber => 'biblio_id',
1699 biblioitemnumber => undef,
1700 barcode => 'external_id',
1701 dateaccessioned => 'acquisition_date',
1702 booksellerid => 'acquisition_source',
1703 homebranch => 'home_library_id',
1704 price => 'purchase_price',
1705 replacementprice => 'replacement_price',
1706 replacementpricedate => 'replacement_price_date',
1707 datelastborrowed => 'last_checkout_date',
1708 datelastseen => 'last_seen_date',
1710 notforloan => 'not_for_loan_status',
1711 damaged => 'damaged_status',
1712 damaged_on => 'damaged_date',
1713 itemlost => 'lost_status',
1714 itemlost_on => 'lost_date',
1715 withdrawn => 'withdrawn',
1716 withdrawn_on => 'withdrawn_date',
1717 itemcallnumber => 'callnumber',
1718 coded_location_qualifier => 'coded_location_qualifier',
1719 issues => 'checkouts_count',
1720 renewals => 'renewals_count',
1721 reserves => 'holds_count',
1722 restricted => 'restricted_status',
1723 itemnotes => 'public_notes',
1724 itemnotes_nonpublic => 'internal_notes',
1725 holdingbranch => 'holding_library_id',
1726 timestamp => 'timestamp',
1727 location => 'location',
1728 permanent_location => 'permanent_location',
1729 onloan => 'checked_out_date',
1730 cn_source => 'call_number_source',
1731 cn_sort => 'call_number_sort',
1732 ccode => 'collection_code',
1733 materials => 'materials_notes',
1735 itype => 'item_type_id',
1736 more_subfields_xml => 'extended_subfields',
1737 enumchron => 'serial_issue_number',
1738 copynumber => 'copy_number',
1739 stocknumber => 'inventory_number',
1740 new_status => 'new_status',
1741 deleted_on => undef,
1747 my $itemtype = $item->itemtype;
1749 Returns Koha object for effective itemtype
1756 return Koha::ItemTypes->find( $self->effective_itemtype );
1759 return shift->itemtype;
1764 my $orders = $item->orders();
1766 Returns a Koha::Acquisition::Orders object
1773 my $orders = $self->_result->item_orders;
1774 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1777 =head3 tracked_links
1779 my $tracked_links = $item->tracked_links();
1781 Returns a Koha::TrackedLinks object
1788 my $tracked_links = $self->_result->linktrackers;
1789 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1794 my $course_item = $item->course_item;
1796 Returns a Koha::Course::Item object
1802 my $rs = $self->_result->course_item;
1804 return Koha::Course::Item->_new_from_dbic($rs);
1807 =head3 move_to_biblio
1809 $item->move_to_biblio($to_biblio[, $params]);
1811 Move the item to another biblio and update any references in other tables.
1813 The final optional parameter, C<$params>, is expected to contain the
1814 'skip_record_index' key, which is relayed down to Koha::Item->store.
1815 There it prevents calling index_records, which takes most of the
1816 time in batch adds/deletes. The caller must take care of calling
1817 index_records separately.
1820 skip_record_index => 1|0
1822 Returns undef if the move failed or the biblionumber of the destination record otherwise
1826 sub move_to_biblio {
1827 my ( $self, $to_biblio, $params ) = @_;
1831 return if $self->biblionumber == $to_biblio->biblionumber;
1833 my $from_biblionumber = $self->biblionumber;
1834 my $to_biblionumber = $to_biblio->biblionumber;
1836 # Own biblionumber and biblioitemnumber
1838 biblionumber => $to_biblionumber,
1839 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1840 })->store({ skip_record_index => $params->{skip_record_index} });
1842 unless ($params->{skip_record_index}) {
1843 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1844 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1847 # Acquisition orders
1848 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1851 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1853 # hold_fill_target (there's no Koha object available yet)
1854 my $hold_fill_target = $self->_result->hold_fill_target;
1855 if ($hold_fill_target) {
1856 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1859 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1860 # and can't even fake one since the significant columns are nullable.
1861 my $storage = $self->_result->result_source->storage;
1864 my ($storage, $dbh, @cols) = @_;
1866 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1871 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1873 return $to_biblionumber;
1878 my $bundle_items = $item->bundle_items;
1880 Returns the items associated with this bundle
1887 my $rs = $self->_result->bundle_items;
1888 return Koha::Items->_new_from_dbic($rs);
1893 my $is_bundle = $item->is_bundle;
1895 Returns whether the item is a bundle or not
1901 return $self->bundle_items->count ? 1 : 0;
1906 my $bundle = $item->bundle_host;
1908 Returns the bundle item this item is attached to
1915 my $bundle_items_rs = $self->_result->item_bundles_item;
1916 return unless $bundle_items_rs;
1917 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1922 my $in_bundle = $item->in_bundle;
1924 Returns whether this item is currently in a bundle
1930 return $self->bundle_host ? 1 : 0;
1933 =head3 add_to_bundle
1935 my $link = $item->add_to_bundle($bundle_item);
1937 Adds the bundle_item passed to this item
1942 my ( $self, $bundle_item, $options ) = @_;
1946 Koha::Exceptions::Item::Bundle::IsBundle->throw()
1947 if ( $self->itemnumber eq $bundle_item->itemnumber
1948 || $bundle_item->is_bundle
1949 || $self->in_bundle );
1951 my $schema = Koha::Database->new->schema;
1953 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1959 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
1961 my $checkout = $bundle_item->checkout;
1963 unless ($options->{force_checkin}) {
1964 Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
1967 my $branchcode = C4::Context->userenv->{'branch'};
1968 my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
1970 Koha::Exceptions::Checkin::FailedCheckin->throw();
1974 my $holds = $bundle_item->current_holds;
1975 if ($holds->count) {
1976 unless ($options->{ignore_holds}) {
1977 Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
1981 $self->_result->add_to_item_bundles_hosts(
1982 { item => $bundle_item->itemnumber } );
1984 $bundle_item->notforloan($BundleNotLoanValue)->store();
1990 # 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
1991 if ( ref($_) eq 'DBIx::Class::Exception' ) {
1992 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1994 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1995 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1996 Koha::Exceptions::Object::FKConstraint->throw(
1997 error => 'Broken FK constraint',
1998 broken_fk => $+{column}
2003 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
2005 Koha::Exceptions::Object::DuplicateID->throw(
2006 error => 'Duplicate ID',
2007 duplicate_id => $+{key}
2010 elsif ( $_->{msg} =~
2011 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
2013 { # The optional \W in the regex might be a quote or backtick
2014 my $type = $+{type};
2015 my $value = $+{value};
2016 my $property = $+{property};
2017 $property =~ s/['`]//g;
2018 Koha::Exceptions::Object::BadValue->throw(
2021 property => $property =~ /(\w+\.\w+)$/
2024 , # results in table.column without quotes or backtics
2028 # Catch-all for foreign key breakages. It will help find other use cases
2037 =head3 remove_from_bundle
2039 Remove this item from any bundle it may have been attached to.
2043 sub remove_from_bundle {
2046 my $bundle_host = $self->bundle_host;
2048 return 0 unless $bundle_host; # Should not we raise an exception here?
2050 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $bundle_host->checkout;
2052 my $bundle_item_rs = $self->_result->item_bundles_item;
2053 if ( $bundle_item_rs ) {
2054 $bundle_item_rs->delete;
2055 $self->notforloan(0)->store();
2061 =head2 Internal methods
2063 =head3 _after_item_action_hooks
2065 Helper method that takes care of calling all plugin hooks
2069 sub _after_item_action_hooks {
2070 my ( $self, $params ) = @_;
2072 my $action = $params->{action};
2074 Koha::Plugins->call(
2075 'after_item_action',
2079 item_id => $self->itemnumber,
2086 my $recall = $item->recall;
2088 Return the relevant recall for this item
2094 my @recalls = Koha::Recalls->search(
2096 biblio_id => $self->biblionumber,
2099 { order_by => { -asc => 'created_date' } }
2102 my $item_level_recall;
2103 foreach my $recall (@recalls) {
2104 if ( $recall->item_level ) {
2105 $item_level_recall = 1;
2106 if ( $recall->item_id == $self->itemnumber ) {
2111 if ($item_level_recall) {
2113 # recall needs to be filled be a specific item only
2114 # no other item is relevant to return
2118 # no item-level recall to return, so return earliest biblio-level
2119 # FIXME: eventually this will be based on priority
2123 =head3 can_be_recalled
2125 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
2127 Does item-level checks and returns if items can be recalled by this borrower
2131 sub can_be_recalled {
2132 my ( $self, $params ) = @_;
2134 return 0 if !( C4::Context->preference('UseRecalls') );
2136 # check if this item is not for loan, withdrawn or lost
2137 return 0 if ( $self->notforloan != 0 );
2138 return 0 if ( $self->itemlost != 0 );
2139 return 0 if ( $self->withdrawn != 0 );
2141 # check if this item is not checked out - if not checked out, can't be recalled
2142 return 0 if ( !defined( $self->checkout ) );
2144 my $patron = $params->{patron};
2146 my $branchcode = C4::Context->userenv->{'branch'};
2148 $branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
2151 # Check the circulation rule for each relevant itemtype for this item
2152 my $rule = Koha::CirculationRules->get_effective_rules({
2153 branchcode => $branchcode,
2154 categorycode => $patron ? $patron->categorycode : undef,
2155 itemtype => $self->effective_itemtype,
2158 'recalls_per_record',
2163 # check recalls allowed has been set and is not zero
2164 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2167 # check borrower has not reached open recalls allowed limit
2168 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
2170 # check borrower has not reach open recalls allowed per record limit
2171 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
2173 # check if this patron has already recalled this item
2174 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
2176 # check if this patron has already checked out this item
2177 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2179 # check if this patron has already reserved this item
2180 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2183 # check item availability
2184 # items are unavailable for recall if they are lost, withdrawn or notforloan
2185 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
2187 # if there are no available items at all, no recall can be placed
2188 return 0 if ( scalar @items == 0 );
2190 my $checked_out_count = 0;
2192 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
2195 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
2196 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
2198 # can't recall if no items have been checked out
2199 return 0 if ( $checked_out_count == 0 );
2205 =head3 can_be_waiting_recall
2207 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
2209 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
2210 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
2214 sub can_be_waiting_recall {
2217 return 0 if !( C4::Context->preference('UseRecalls') );
2219 # check if this item is not for loan, withdrawn or lost
2220 return 0 if ( $self->notforloan != 0 );
2221 return 0 if ( $self->itemlost != 0 );
2222 return 0 if ( $self->withdrawn != 0 );
2224 my $branchcode = $self->holdingbranch;
2225 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
2226 $branchcode = C4::Context->userenv->{'branch'};
2228 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
2231 # Check the circulation rule for each relevant itemtype for this item
2232 my $most_relevant_recall = $self->check_recalls;
2233 my $rule = Koha::CirculationRules->get_effective_rules(
2235 branchcode => $branchcode,
2236 categorycode => $most_relevant_recall ? $most_relevant_recall->patron->categorycode : undef,
2237 itemtype => $self->effective_itemtype,
2238 rules => [ 'recalls_allowed', ],
2242 # check recalls allowed has been set and is not zero
2243 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2249 =head3 check_recalls
2251 my $recall = $item->check_recalls;
2253 Get the most relevant recall for this item.
2260 my @recalls = Koha::Recalls->search(
2261 { biblio_id => $self->biblionumber,
2262 item_id => [ $self->itemnumber, undef ]
2264 { order_by => { -asc => 'created_date' } }
2265 )->filter_by_current->as_list;
2268 # iterate through relevant recalls to find the best one.
2269 # if we come across a waiting recall, use this one.
2270 # 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.
2271 foreach my $r ( @recalls ) {
2272 if ( $r->waiting ) {
2277 unless ( defined $recall ) {
2278 $recall = $recalls[0];
2284 =head3 is_notforloan
2286 my $is_notforloan = $item->is_notforloan;
2288 Determine whether or not this item is "notforloan" based on
2289 the item's notforloan status or its item type
2295 my $is_notforloan = 0;
2297 if ( $self->notforloan ){
2301 my $itemtype = $self->itemtype;
2303 if ( $itemtype->notforloan ){
2309 return $is_notforloan;
2312 =head3 is_denied_renewal
2314 my $is_denied_renewal = $item->is_denied_renewal;
2316 Determine whether or not this item can be renewed based on the
2317 rules set in the ItemsDeniedRenewal system preference.
2321 sub is_denied_renewal {
2323 my $denyingrules = C4::Context->yaml_preference('ItemsDeniedRenewal');
2324 return 0 unless $denyingrules;
2325 foreach my $field (keys %$denyingrules) {
2326 # Silently ignore bad column names; TODO we should validate elsewhere
2327 next if !$self->_result->result_source->has_column($field);
2328 my $val = $self->$field;
2329 if( !defined $val) {
2330 if ( any { !defined $_ } @{$denyingrules->{$field}} ){
2333 } elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
2334 # If the results matches the values in the syspref
2335 # We return true if match found
2344 Returns a map of column name to string representations including the string,
2345 the mapping type and the mapping category where appropriate.
2347 Currently handles authorised value mappings, library, callnumber and itemtype
2350 Accepts a param hashref where the 'public' key denotes whether we want the public
2351 or staff client strings.
2356 my ( $self, $params ) = @_;
2357 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
2358 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
2359 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
2361 my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
2363 # Hardcoded known 'authorised_value' values mapped to API codes
2364 my $code_to_type = {
2365 branches => 'library',
2366 cn_source => 'call_number_source',
2367 itemtypes => 'item_type',
2370 # Handle not null and default values for integers and dates
2373 foreach my $col ( @{$self->_columns} ) {
2375 # By now, we are done with known columns, now check the framework for mappings
2376 my $field = $self->_result->result_source->name . '.' . $col;
2378 # Check there's an entry in the MARC subfield structure for the field
2379 if ( exists $mss->{$field}
2380 && scalar @{ $mss->{$field} } > 0
2381 && $mss->{$field}[0]->{authorised_value} )
2383 my $subfield = $mss->{$field}[0];
2384 my $code = $subfield->{authorised_value};
2386 my $str = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
2387 my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
2388 $strings->{$col} = {
2391 ( $type eq 'av' ? ( category => $code ) : () ),
2400 =head3 location_update_trigger
2402 $item->location_update_trigger( $action );
2404 Updates the item location based on I<$action>. It is done like this:
2408 =item For B<checkin>, location is updated following the I<UpdateItemLocationOnCheckin> preference.
2410 =item For B<checkout>, location is updated following the I<UpdateItemLocationOnCheckout> preference.
2414 FIXME: It should return I<$self>. See bug 35270.
2418 sub location_update_trigger {
2419 my ( $self, $action ) = @_;
2421 my ( $update_loc_rules, $messages );
2422 if ( $action eq 'checkin' ) {
2423 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckin');
2425 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckout');
2428 if ($update_loc_rules) {
2429 if ( defined $update_loc_rules->{_ALL_} ) {
2430 if ( $update_loc_rules->{_ALL_} eq '_PERM_' ) {
2431 $update_loc_rules->{_ALL_} = $self->permanent_location;
2433 if ( $update_loc_rules->{_ALL_} eq '_BLANK_' ) {
2434 $update_loc_rules->{_ALL_} = '';
2437 ( defined $self->location && $self->location ne $update_loc_rules->{_ALL_} )
2438 || ( !defined $self->location
2439 && $update_loc_rules->{_ALL_} ne "" )
2442 $messages->{'ItemLocationUpdated'} =
2443 { from => $self->location, to => $update_loc_rules->{_ALL_} };
2444 $self->location( $update_loc_rules->{_ALL_} )->store(
2447 skip_record_index => 1,
2448 skip_holds_queue => 1
2453 foreach my $key ( keys %$update_loc_rules ) {
2454 if ( $update_loc_rules->{$key} eq '_PERM_' ) {
2455 $update_loc_rules->{$key} = $self->permanent_location;
2456 } elsif ( $update_loc_rules->{$key} eq '_BLANK_' ) {
2457 $update_loc_rules->{$key} = '';
2461 defined $self->location
2462 && $self->location eq $key
2463 && $self->location ne $update_loc_rules->{$key}
2465 || ( $key eq '_BLANK_'
2466 && ( !defined $self->location || $self->location eq '' )
2467 && $update_loc_rules->{$key} ne '' )
2470 $messages->{'ItemLocationUpdated'} = {
2471 from => $self->location,
2472 to => $update_loc_rules->{$key}
2474 $self->location( $update_loc_rules->{$key} )->store(
2477 skip_record_index => 1,
2478 skip_holds_queue => 1
2499 Kyle M Hall <kyle@bywatersolutions.com>