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();
388 return shift->home_library(@_);
393 my $library = $item->home_library
395 Return the Koha::Library object representing the home library
401 my $hb_rs = $self->_result->homebranch;
403 return Koha::Library->_new_from_dbic( $hb_rs );
406 =head3 holding_branch
411 return shift->holding_library(@_);
414 =head3 holding_library
416 my $library = $item->holding_library
418 Return the Koha::Library object representing the holding library
422 sub holding_library {
425 my $hb_rs = $self->_result->holdingbranch;
427 return Koha::Library->_new_from_dbic( $hb_rs );
432 my $biblio = $item->biblio;
434 Return the bibliographic record of this item
440 my $biblio_rs = $self->_result->biblio;
441 return Koha::Biblio->_new_from_dbic( $biblio_rs );
446 my $biblioitem = $item->biblioitem;
448 Return the biblioitem record of this item
454 my $biblioitem_rs = $self->_result->biblioitem;
455 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
460 my $checkout = $item->checkout;
462 Return the checkout for this item
468 my $checkout_rs = $self->_result->issue;
469 return unless $checkout_rs;
470 return Koha::Checkout->_new_from_dbic( $checkout_rs );
479 my $rs = $self->_result->serial_item;
481 return Koha::Serial::Item->_new_from_dbic($rs);
486 my $item_group = $item->item_group;
488 Return the item group for this item
495 my $item_group_item = $self->_result->item_group_item;
496 return unless $item_group_item;
498 my $item_group_rs = $item_group_item->item_group;
499 return unless $item_group_rs;
501 my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs );
505 =head3 item_group_item
507 my $item_group_item = $item->item_group_item;
509 Return the item group for this item
513 sub item_group_item {
515 my $rs = $self->_result->item_group_item;
517 return Koha::Biblio::ItemGroup::Item->_new_from_dbic($rs);
523 my $return_claims = $item->return_claims;
525 Return any return_claims associated with this item
530 my ( $self, $params, $attrs ) = @_;
531 my $claims_rs = $self->_result->return_claims->search($params, $attrs);
532 return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs );
537 my $return_claim = $item->return_claim;
539 Returns the most recent unresolved return_claims associated with this item
546 $self->_result->return_claims->search( { resolution => undef },
547 { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single;
548 return unless $claims_rs;
549 return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs);
554 my $holds = $item->holds();
555 my $holds = $item->holds($params);
556 my $holds = $item->holds({ found => 'W'});
558 Return holds attached to an item, optionally accept a hashref of params to pass to search
563 my ( $self,$params ) = @_;
564 my $holds_rs = $self->_result->reserves->search($params);
565 return Koha::Holds->_new_from_dbic( $holds_rs );
570 my $bookings = $item->bookings();
572 Returns the bookings attached to this item.
577 my ( $self, $params ) = @_;
578 my $bookings_rs = $self->_result->bookings->search($params);
579 return Koha::Bookings->_new_from_dbic($bookings_rs);
584 my $booking = $item->find_booking( { checkout_date => $now, due_date => $future_date } );
586 Find the first booking that would conflict with the passed checkout dates for this item.
588 FIXME: This can be simplified, it was originally intended to iterate all biblio level bookings
589 to catch cases where this item may be the last available to satisfy a biblio level only booking.
590 However, we dropped the biblio level functionality prior to push as bugs were found in it's
596 my ( $self, $params ) = @_;
598 my $checkout_date = $params->{checkout_date};
599 my $due_date = $params->{due_date};
600 my $biblio = $self->biblio;
602 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
603 my $bookings = $biblio->bookings(
605 # Proposed checkout starts during booked period
608 $dtf->format_datetime($checkout_date),
609 $dtf->format_datetime($due_date)
613 # Proposed checkout is due during booked period
616 $dtf->format_datetime($checkout_date),
617 $dtf->format_datetime($due_date)
621 # Proposed checkout would contain the booked period
623 start_date => { '<' => $dtf->format_datetime($checkout_date) },
624 end_date => { '>' => $dtf->format_datetime($due_date) }
627 { order_by => { '-asc' => 'start_date' } }
631 my $loanable_items = {};
632 my $bookable_items = $biblio->bookable_items;
633 while ( my $item = $bookable_items->next ) {
634 $loanable_items->{ $item->itemnumber } = 1;
635 if ( my $checkout = $item->checkout ) {
636 $checkouts->{ $item->itemnumber } = dt_from_string( $checkout->date_due );
640 while ( my $booking = $bookings->next ) {
642 # Booking for this item
643 if ( defined( $booking->item_id )
644 && $booking->item_id == $self->itemnumber )
649 # Booking for another item
650 elsif ( defined( $booking->item_id ) ) {
651 # Due for another booking, remove from pool
652 delete $loanable_items->{ $booking->item_id };
657 # Booking for any item
659 # Can another item satisfy this booking?
668 $item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
670 Returns a boolean denoting whether the passed booking can be made without clashing.
672 Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
677 my ( $self, $params ) = @_;
679 my $start_date = dt_from_string( $params->{start_date} );
680 my $end_date = dt_from_string( $params->{end_date} );
681 my $booking_id = $params->{booking_id};
683 if ( my $checkout = $self->checkout ) {
684 return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
687 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
689 my $existing_bookings = $self->bookings(
693 $dtf->format_datetime($start_date),
694 $dtf->format_datetime($end_date)
699 $dtf->format_datetime($start_date),
700 $dtf->format_datetime($end_date)
704 start_date => { '<' => $dtf->format_datetime($start_date) },
705 end_date => { '>' => $dtf->format_datetime($end_date) }
712 ? $existing_bookings->search( { booking_id => { '!=' => $booking_id } } )->count
713 : $existing_bookings->count;
715 return $bookings_count ? 0 : 1;
718 =head3 request_transfer
720 my $transfer = $item->request_transfer(
724 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
728 Add a transfer request for this item to the given branch for the given reason.
730 An exception will be thrown if the BranchTransferLimits would prevent the requested
731 transfer, unless 'ignore_limits' is passed to override the limits.
733 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
734 The caller should catch such cases and retry the transfer request as appropriate passing
735 an appropriate override.
738 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
739 * replace - Used to replace the existing transfer request with your own.
743 sub request_transfer {
744 my ( $self, $params ) = @_;
746 # check for mandatory params
747 my @mandatory = ( 'to', 'reason' );
748 for my $param (@mandatory) {
749 unless ( defined( $params->{$param} ) ) {
750 Koha::Exceptions::MissingParameter->throw(
751 error => "The $param parameter is mandatory" );
755 Koha::Exceptions::Item::Transfer::Limit->throw()
756 unless ( $params->{ignore_limits}
757 || $self->can_be_transferred( { to => $params->{to} } ) );
759 my $request = $self->get_transfer;
760 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
761 if ( $request && !$params->{enqueue} && !$params->{replace} );
763 $request->cancel( { reason => $params->{reason}, force => 1 } )
764 if ( defined($request) && $params->{replace} );
766 my $transfer = Koha::Item::Transfer->new(
768 itemnumber => $self->itemnumber,
769 daterequested => dt_from_string,
770 frombranch => $self->holdingbranch,
771 tobranch => $params->{to}->branchcode,
772 reason => $params->{reason},
773 comments => $params->{comment}
782 my $transfer = $item->get_transfer;
784 Return the active transfer request or undef
786 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
787 whereby the most recently sent, but not received, transfer will be returned
788 if it exists, otherwise the oldest unsatisfied transfer will be returned.
790 This allows for transfers to queue, which is the case for stock rotation and
791 rotating collections where a manual transfer may need to take precedence but
792 we still expect the item to end up at a final location eventually.
799 my $transfer = $self->_result->current_branchtransfers->next;
800 return Koha::Item::Transfer->_new_from_dbic($transfer) if $transfer;
805 my $transfer = $item->transfer;
807 Returns the active transfer request. Returns I<undef> if no active transfer
810 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
811 whereby the most recently sent, but not received, transfer will be returned
812 if it exists, otherwise the oldest unsatisfied transfer will be returned.
814 This allows for transfers to queue, which is the case for stock rotation and
815 rotating collections where a manual transfer may need to take precedence but
816 we still expect the item to end up at a final location eventually.
821 return shift->get_transfer(@_);
826 my $transfer = $item->get_transfers;
828 Return the list of outstanding transfers (i.e requested but not yet cancelled
831 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
832 whereby the most recently sent, but not received, transfer will be returned
833 first if it exists, otherwise requests are in oldest to newest request order.
835 This allows for transfers to queue, which is the case for stock rotation and
836 rotating collections where a manual transfer may need to take precedence but
837 we still expect the item to end up at a final location eventually.
844 my $transfer_rs = $self->_result->current_branchtransfers;
846 return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
849 =head3 last_returned_by
851 Gets and sets the last patron to return an item.
853 Accepts a patron's id (borrowernumber) and returns Koha::Patron objects
855 $item->last_returned_by( $borrowernumber );
857 my $patron = $item->last_returned_by();
861 sub last_returned_by {
862 my ( $self, $borrowernumber ) = @_;
863 if ( $borrowernumber ) {
864 $self->_result->update_or_create_related('last_returned_by',
865 { borrowernumber => $borrowernumber, itemnumber => $self->itemnumber } );
867 my $rs = $self->_result->last_returned_by;
869 return Koha::Patron->_new_from_dbic($rs->borrowernumber);
872 =head3 can_article_request
874 my $bool = $item->can_article_request( $borrower )
876 Returns true if item can be specifically requested
878 $borrower must be a Koha::Patron object
882 sub can_article_request {
883 my ( $self, $borrower ) = @_;
885 my $rule = $self->article_request_type($borrower);
887 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
891 =head3 hidden_in_opac
893 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
895 Returns true if item fields match the hidding criteria defined in $rules.
896 Returns false otherwise.
898 Takes HASHref that can have the following parameters:
900 $rules : { <field> => [ value_1, ... ], ... }
902 Note: $rules inherits its structure from the parsed YAML from reading
903 the I<OpacHiddenItems> system preference.
908 my ( $self, $params ) = @_;
910 my $rules = $params->{rules} // {};
913 if C4::Context->preference('hidelostitems') and
916 my $hidden_in_opac = 0;
918 foreach my $field ( keys %{$rules} ) {
920 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
926 return $hidden_in_opac;
929 =head3 can_be_transferred
931 $item->can_be_transferred({ to => $to_library, from => $from_library })
932 Checks if an item can be transferred to given library.
934 This feature is controlled by two system preferences:
935 UseBranchTransferLimits to enable / disable the feature
936 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
937 for setting the limitations
939 Takes HASHref that can have the following parameters:
940 MANDATORY PARAMETERS:
943 $from : Koha::Library # if not given, item holdingbranch
944 # will be used instead
946 Returns 1 if item can be transferred to $to_library, otherwise 0.
948 To find out whether at least one item of a Koha::Biblio can be transferred, please
949 see Koha::Biblio->can_be_transferred() instead of using this method for
950 multiple items of the same biblio.
954 sub can_be_transferred {
955 my ($self, $params) = @_;
957 my $to = $params->{to};
958 my $from = $params->{from};
960 $to = $to->branchcode;
961 $from = defined $from ? $from->branchcode : $self->holdingbranch;
963 return 1 if $from eq $to; # Transfer to current branch is allowed
964 return 1 unless C4::Context->preference('UseBranchTransferLimits');
966 my $limittype = C4::Context->preference('BranchTransferLimitsType');
967 return Koha::Item::Transfer::Limits->search({
970 $limittype => $limittype eq 'itemtype'
971 ? $self->effective_itemtype : $self->ccode
976 =head3 pickup_locations
978 my $pickup_locations = $item->pickup_locations({ patron => $patron })
980 Returns possible pickup locations for this item, according to patron's home library
981 and if item can be transferred to each pickup location.
983 Throws a I<Koha::Exceptions::MissingParameter> exception if the B<mandatory> parameter I<patron>
988 sub pickup_locations {
989 my ($self, $params) = @_;
991 Koha::Exceptions::MissingParameter->throw( parameter => 'patron' )
992 unless exists $params->{patron};
994 my $patron = $params->{patron};
996 my $circ_control_branch = Koha::Policy::Holds->holds_control_library( $self, $patron );
998 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
1000 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
1001 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
1003 my $pickup_libraries = Koha::Libraries->search();
1004 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
1005 $pickup_libraries = $self->home_branch->get_hold_libraries;
1006 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
1007 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
1008 $pickup_libraries = $plib->get_hold_libraries;
1009 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
1010 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
1011 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
1012 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
1015 return $pickup_libraries->search(
1017 pickup_location => 1
1020 order_by => ['branchname']
1022 ) unless C4::Context->preference('UseBranchTransferLimits');
1024 my $limittype = C4::Context->preference('BranchTransferLimitsType');
1025 my ($ccode, $itype) = (undef, undef);
1026 if( $limittype eq 'ccode' ){
1027 $ccode = $self->ccode;
1029 $itype = $self->itype;
1031 my $limits = Koha::Item::Transfer::Limits->search(
1033 fromBranch => $self->holdingbranch,
1037 { columns => ['toBranch'] }
1040 return $pickup_libraries->search(
1042 pickup_location => 1,
1044 '-not_in' => $limits->_resultset->as_query
1048 order_by => ['branchname']
1053 =head3 article_request_type
1055 my $type = $item->article_request_type( $borrower )
1057 returns 'yes', 'no', 'bib_only', or 'item_only'
1059 $borrower must be a Koha::Patron object
1063 sub article_request_type {
1064 my ( $self, $borrower ) = @_;
1066 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
1068 $branch_control eq 'homebranch' ? $self->homebranch
1069 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
1071 my $borrowertype = $borrower->categorycode;
1072 my $itemtype = $self->effective_itemtype();
1073 my $rule = Koha::CirculationRules->get_effective_rule(
1075 rule_name => 'article_requests',
1076 categorycode => $borrowertype,
1077 itemtype => $itemtype,
1078 branchcode => $branchcode
1082 return q{} unless $rule;
1083 return $rule->rule_value || q{}
1086 =head3 current_holds
1092 my $attributes = { order_by => 'priority' };
1093 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1095 itemnumber => $self->itemnumber,
1098 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
1099 waitingdate => { '!=' => undef },
1102 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
1103 return Koha::Holds->_new_from_dbic($hold_rs);
1108 my $first_hold = $item->first_hold;
1110 Returns the first I<Koha::Hold> for the item.
1116 return $self->current_holds->next;
1119 =head3 stockrotationitem
1121 my $sritem = Koha::Item->stockrotationitem;
1123 Returns the stock rotation item associated with the current item.
1127 sub stockrotationitem {
1129 my $rs = $self->_result->stockrotationitem;
1131 return Koha::StockRotationItem->_new_from_dbic( $rs );
1136 my $item = $item->add_to_rota($rota_id);
1138 Add this item to the rota identified by $ROTA_ID, which means associating it
1139 with the first stage of that rota. Should this item already be associated
1140 with a rota, then we will move it to the new rota.
1145 my ( $self, $rota_id ) = @_;
1146 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
1150 =head3 has_pending_hold
1152 my $is_pending_hold = $item->has_pending_hold();
1154 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
1158 sub has_pending_hold {
1160 return $self->_result->tmp_holdsqueue ? 1 : 0;
1163 =head3 has_pending_recall {
1165 my $has_pending_recall
1167 Return if whether has pending recall of not.
1171 sub has_pending_recall {
1174 # FIXME Must be moved to $self->recalls
1175 return Koha::Recalls->search(
1177 item_id => $self->itemnumber,
1178 status => 'waiting',
1183 =head3 as_marc_field
1185 my $field = $item->as_marc_field;
1187 This method returns a MARC::Field object representing the Koha::Item object
1188 with the current mappings configuration.
1195 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1197 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
1201 my $item_field = $tagslib->{$itemtag};
1203 my $more_subfields = $self->additional_attributes->to_hashref;
1204 foreach my $subfield (
1206 $a->{display_order} <=> $b->{display_order}
1207 || $a->{subfield} cmp $b->{subfield}
1208 } grep { ref($_) && %$_ } values %$item_field
1211 my $kohafield = $subfield->{kohafield};
1212 my $tagsubfield = $subfield->{tagsubfield};
1214 if ( defined $kohafield && $kohafield ne '' ) {
1215 next if $kohafield !~ m{^items\.}; # That would be weird!
1216 ( my $attribute = $kohafield ) =~ s|^items\.||;
1217 $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
1218 if defined $self->$attribute and $self->$attribute ne '';
1220 $value = $more_subfields->{$tagsubfield}
1223 next unless defined $value
1226 if ( $subfield->{repeatable} ) {
1227 my @values = split '\|', $value;
1228 push @subfields, ( $tagsubfield => $_ ) for @values;
1231 push @subfields, ( $tagsubfield => $value );
1236 return unless @subfields;
1238 return MARC::Field->new(
1239 "$itemtag", ' ', ' ', @subfields
1243 =head3 renewal_branchcode
1245 Returns the branchcode to be recorded in statistics renewal of the item
1249 sub renewal_branchcode {
1251 my ( $self, $params ) = @_;
1253 my $interface = C4::Context->interface;
1255 my $renewal_branchcode;
1257 if ( $interface eq 'opac' ) {
1258 $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1259 if ( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ) {
1260 $branchcode = 'OPACRenew';
1262 } elsif ( $interface eq 'api' ) {
1263 $renewal_branchcode = C4::Context->preference('RESTAPIRenewalBranch');
1264 if ( !defined $renewal_branchcode || $renewal_branchcode eq 'apirenew' ) {
1265 $branchcode = 'APIRenew';
1269 return $branchcode if $branchcode;
1271 if ($renewal_branchcode) {
1272 if ( $renewal_branchcode eq 'itemhomebranch' ) {
1273 $branchcode = $self->homebranch;
1274 } elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1275 $branchcode = $self->checkout->patron->branchcode;
1276 } elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1277 $branchcode = $self->checkout->branchcode;
1278 } elsif ( $renewal_branchcode eq 'apiuserbranch' ) {
1280 ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1281 ? C4::Context->userenv->{branch}
1282 : $params->{branch};
1288 ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1289 ? C4::Context->userenv->{branch}
1290 : $params->{branch};
1297 Return the cover images associated with this item.
1304 my $cover_images_rs = $self->_result->cover_images;
1305 return Koha::CoverImages->_new_from_dbic($cover_images_rs);
1308 =head3 cover_image_ids
1310 Return the cover image ids associated with this item.
1314 sub cover_image_ids {
1316 return [ $self->cover_images->get_column('imagenumber') ];
1319 =head3 columns_to_str
1321 my $values = $items->columns_to_str;
1323 Return a hashref with the string representation of the different attribute of the item.
1325 This is meant to be used for display purpose only.
1329 sub columns_to_str {
1331 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
1332 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
1333 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1335 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1338 for my $column ( @{$self->_columns}) {
1340 next if $column eq 'more_subfields_xml';
1342 my $value = $self->$column;
1343 # 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
1345 if ( not defined $value or $value eq "" ) {
1346 $values->{$column} = $value;
1351 exists $mss->{"items.$column"}
1352 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1355 $values->{$column} =
1357 ? $subfield->{authorised_value}
1358 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1359 $subfield->{tagsubfield}, $value, '', $tagslib )
1365 $self->more_subfields_xml
1366 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1371 my ( $field ) = $marc_more->fields;
1372 for my $sf ( $field->subfields ) {
1373 my $subfield_code = $sf->[0];
1374 my $value = $sf->[1];
1375 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1376 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1378 $subfield->{authorised_value}
1379 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1380 $subfield->{tagsubfield}, $value, '', $tagslib )
1383 push @{$more_values->{$subfield_code}}, $value;
1386 while ( my ( $k, $v ) = each %$more_values ) {
1387 $values->{$k} = join ' | ', @$v;
1394 =head3 additional_attributes
1396 my $attributes = $item->additional_attributes;
1397 $attributes->{k} = 'new k';
1398 $item->update({ more_subfields => $attributes->to_marcxml });
1400 Returns a Koha::Item::Attributes object that represents the non-mapped
1401 attributes for this item.
1405 sub additional_attributes {
1408 return Koha::Item::Attributes->new_from_marcxml(
1409 $self->more_subfields_xml,
1413 =head3 _set_found_trigger
1415 $self->_set_found_trigger
1417 Finds the most recent lost item charge for this item and refunds the patron
1418 appropriately, taking into account any payments or writeoffs already applied
1421 Internal function, not exported, called only by Koha::Item->store.
1425 sub _set_found_trigger {
1426 my ( $self, $pre_mod_item ) = @_;
1428 # Reverse any lost item charges if necessary.
1429 my $no_refund_after_days =
1430 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1431 if ($no_refund_after_days) {
1432 my $today = dt_from_string();
1433 my $lost_age_in_days =
1434 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1437 return $self unless $lost_age_in_days < $no_refund_after_days;
1440 my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
1443 return_branch => C4::Context->userenv
1444 ? C4::Context->userenv->{'branch'}
1448 my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
1450 if ( $lostreturn_policy ) {
1452 # refund charge made for lost book
1453 my $lost_charge = Koha::Account::Lines->search(
1455 itemnumber => $self->itemnumber,
1456 debit_type_code => 'LOST',
1457 status => [ undef, { '<>' => 'FOUND' } ]
1460 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1465 if ( $lost_charge ) {
1467 my $patron = $lost_charge->patron;
1470 my $account = $patron->account;
1472 # Credit outstanding amount
1473 my $credit_total = $lost_charge->amountoutstanding;
1477 $lost_charge->amount > $lost_charge->amountoutstanding &&
1478 $lostreturn_policy ne "refund_unpaid"
1480 # some amount has been cancelled. collect the offsets that are not writeoffs
1481 # this works because the only way to subtract from this kind of a debt is
1482 # using the UI buttons 'Pay' and 'Write off'
1484 # We don't credit any payments if return policy is
1487 # In that case only unpaid/outstanding amount
1488 # will be credited which settles the debt without
1489 # creating extra credits
1491 my $credit_offsets = $lost_charge->debit_offsets(
1493 'credit_id' => { '!=' => undef },
1494 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1496 { join => 'credit' }
1499 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1500 # credits are negative on the DB
1501 $credit_offsets->total * -1 :
1503 # Credit the outstanding amount, then add what has been
1504 # paid to create a net credit for this amount
1505 $credit_total += $total_to_refund;
1509 if ( $credit_total > 0 ) {
1511 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1512 $credit = $account->add_credit(
1514 amount => $credit_total,
1515 description => 'Item found ' . $self->itemnumber,
1516 type => 'LOST_FOUND',
1517 interface => C4::Context->interface,
1518 library_id => $branchcode,
1519 item_id => $self->itemnumber,
1520 issue_id => $lost_charge->issue_id
1524 $credit->apply( { debits => [$lost_charge] } );
1528 message => 'lost_refunded',
1529 payload => { credit_id => $credit->id }
1534 # Update the account status
1535 $lost_charge->status('FOUND');
1536 $lost_charge->store();
1538 # Reconcile balances if required
1539 if ( C4::Context->preference('AccountAutoReconcile') ) {
1540 $account->reconcile_balance;
1545 # possibly restore fine for lost book
1546 my $lost_overdue = Koha::Account::Lines->search(
1548 itemnumber => $self->itemnumber,
1549 debit_type_code => 'OVERDUE',
1553 order_by => { '-desc' => 'date' },
1557 if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
1559 my $patron = $lost_overdue->patron;
1561 my $account = $patron->account;
1563 # Update status of fine
1564 $lost_overdue->status('FOUND')->store();
1566 # Find related forgive credit
1567 my $refund = $lost_overdue->credits(
1569 credit_type_code => 'FORGIVEN',
1570 itemnumber => $self->itemnumber,
1571 status => [ { '!=' => 'VOID' }, undef ]
1573 { order_by => { '-desc' => 'date' }, rows => 1 }
1577 # Revert the forgive credit
1578 $refund->void({ interface => 'trigger' });
1582 message => 'lost_restored',
1583 payload => { refund_id => $refund->id }
1588 # Reconcile balances if required
1589 if ( C4::Context->preference('AccountAutoReconcile') ) {
1590 $account->reconcile_balance;
1594 } elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
1598 message => 'lost_charge',
1604 my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
1606 if ( $processingreturn_policy ) {
1608 # refund processing charge made for lost book
1609 my $processing_charge = Koha::Account::Lines->search(
1611 itemnumber => $self->itemnumber,
1612 debit_type_code => 'PROCESSING',
1613 status => [ undef, { '<>' => 'FOUND' } ]
1616 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1621 if ( $processing_charge ) {
1623 my $patron = $processing_charge->patron;
1626 my $account = $patron->account;
1628 # Credit outstanding amount
1629 my $credit_total = $processing_charge->amountoutstanding;
1633 $processing_charge->amount > $processing_charge->amountoutstanding &&
1634 $processingreturn_policy ne "refund_unpaid"
1636 # some amount has been cancelled. collect the offsets that are not writeoffs
1637 # this works because the only way to subtract from this kind of a debt is
1638 # using the UI buttons 'Pay' and 'Write off'
1640 # We don't credit any payments if return policy is
1643 # In that case only unpaid/outstanding amount
1644 # will be credited which settles the debt without
1645 # creating extra credits
1647 my $credit_offsets = $processing_charge->debit_offsets(
1649 'credit_id' => { '!=' => undef },
1650 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1652 { join => 'credit' }
1655 my $total_to_refund = ( $credit_offsets->count > 0 ) ?
1656 # credits are negative on the DB
1657 $credit_offsets->total * -1 :
1659 # Credit the outstanding amount, then add what has been
1660 # paid to create a net credit for this amount
1661 $credit_total += $total_to_refund;
1665 if ( $credit_total > 0 ) {
1667 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1668 $credit = $account->add_credit(
1670 amount => $credit_total,
1671 description => 'Item found ' . $self->itemnumber,
1672 type => 'PROCESSING_FOUND',
1673 interface => C4::Context->interface,
1674 library_id => $branchcode,
1675 item_id => $self->itemnumber,
1676 issue_id => $processing_charge->issue_id
1680 $credit->apply( { debits => [$processing_charge] } );
1684 message => 'processing_refunded',
1685 payload => { credit_id => $credit->id }
1690 # Update the account status
1691 $processing_charge->status('FOUND');
1692 $processing_charge->store();
1694 # Reconcile balances if required
1695 if ( C4::Context->preference('AccountAutoReconcile') ) {
1696 $account->reconcile_balance;
1705 =head3 public_read_list
1707 This method returns the list of publicly readable database fields for both API and UI output purposes
1711 sub public_read_list {
1713 'itemnumber', 'biblionumber', 'homebranch',
1714 'holdingbranch', 'location', 'collectioncode',
1715 'itemcallnumber', 'copynumber', 'enumchron',
1716 'barcode', 'dateaccessioned', 'itemnotes',
1717 'onloan', 'uri', 'itype',
1718 'notforloan', 'damaged', 'itemlost',
1719 'withdrawn', 'restricted'
1725 Overloaded to_api method to ensure item-level itypes is adhered to.
1730 my ($self, $params) = @_;
1732 my $response = $self->SUPER::to_api($params);
1735 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1737 my $itype_notforloan = $self->itemtype->notforloan;
1738 $overrides->{effective_not_for_loan_status} =
1739 ( defined $itype_notforloan && !$self->notforloan ) ? $itype_notforloan : $self->notforloan;
1741 return { %$response, %$overrides };
1744 =head3 to_api_mapping
1746 This method returns the mapping for representing a Koha::Item object
1751 sub to_api_mapping {
1753 itemnumber => 'item_id',
1754 biblionumber => 'biblio_id',
1755 biblioitemnumber => undef,
1756 barcode => 'external_id',
1757 dateaccessioned => 'acquisition_date',
1758 booksellerid => 'acquisition_source',
1759 homebranch => 'home_library_id',
1760 price => 'purchase_price',
1761 replacementprice => 'replacement_price',
1762 replacementpricedate => 'replacement_price_date',
1763 datelastborrowed => 'last_checkout_date',
1764 datelastseen => 'last_seen_date',
1766 notforloan => 'not_for_loan_status',
1767 damaged => 'damaged_status',
1768 damaged_on => 'damaged_date',
1769 itemlost => 'lost_status',
1770 itemlost_on => 'lost_date',
1771 withdrawn => 'withdrawn',
1772 withdrawn_on => 'withdrawn_date',
1773 itemcallnumber => 'callnumber',
1774 coded_location_qualifier => 'coded_location_qualifier',
1775 issues => 'checkouts_count',
1776 renewals => 'renewals_count',
1777 reserves => 'holds_count',
1778 restricted => 'restricted_status',
1779 itemnotes => 'public_notes',
1780 itemnotes_nonpublic => 'internal_notes',
1781 holdingbranch => 'holding_library_id',
1782 timestamp => 'timestamp',
1783 location => 'location',
1784 permanent_location => 'permanent_location',
1785 onloan => 'checked_out_date',
1786 cn_source => 'call_number_source',
1787 cn_sort => 'call_number_sort',
1788 ccode => 'collection_code',
1789 materials => 'materials_notes',
1791 itype => 'item_type_id',
1792 more_subfields_xml => 'extended_subfields',
1793 enumchron => 'serial_issue_number',
1794 copynumber => 'copy_number',
1795 stocknumber => 'inventory_number',
1796 new_status => 'new_status',
1797 deleted_on => undef,
1803 my $itemtype = $item->itemtype;
1805 Returns Koha object for effective itemtype
1812 return Koha::ItemTypes->find( $self->effective_itemtype );
1817 my $item_type = $item->item_type;
1819 Returns the effective I<Koha::ItemType> for the item.
1821 FIXME: it should either return the 'real item type' or undef if no item type
1822 defined. And effective_itemtype should return... the effective itemtype. Right
1823 now it returns an id... This is all inconsistent. And the API should make it clear
1824 if the attribute is part of the resource, or a calculated value i.e. if the item
1825 is not linked to an item type on its own, then the API response should contain
1826 item_type: null! And the effective item type... be another attribute. I understand
1827 that this complicates filtering, but some query trickery could do it in the controller.
1832 return shift->itemtype;
1837 my $orders = $item->orders();
1839 Returns a Koha::Acquisition::Orders object
1846 my $orders = $self->_result->item_orders;
1847 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1850 =head3 tracked_links
1852 my $tracked_links = $item->tracked_links();
1854 Returns a Koha::TrackedLinks object
1861 my $tracked_links = $self->_result->linktrackers;
1862 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1867 my $course_item = $item->course_item;
1869 Returns a Koha::Course::Item object
1875 my $rs = $self->_result->course_item;
1877 return Koha::Course::Item->_new_from_dbic($rs);
1880 =head3 move_to_biblio
1882 $item->move_to_biblio($to_biblio[, $params]);
1884 Move the item to another biblio and update any references in other tables.
1886 The final optional parameter, C<$params>, is expected to contain the
1887 'skip_record_index' key, which is relayed down to Koha::Item->store.
1888 There it prevents calling index_records, which takes most of the
1889 time in batch adds/deletes. The caller must take care of calling
1890 index_records separately.
1893 skip_record_index => 1|0
1895 Returns undef if the move failed or the biblionumber of the destination record otherwise
1899 sub move_to_biblio {
1900 my ( $self, $to_biblio, $params ) = @_;
1904 return if $self->biblionumber == $to_biblio->biblionumber;
1906 my $from_biblionumber = $self->biblionumber;
1907 my $to_biblionumber = $to_biblio->biblionumber;
1909 # Own biblionumber and biblioitemnumber
1911 biblionumber => $to_biblionumber,
1912 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1913 })->store({ skip_record_index => $params->{skip_record_index} });
1915 unless ($params->{skip_record_index}) {
1916 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1917 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1920 # Acquisition orders
1921 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1924 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1926 # hold_fill_target (there's no Koha object available yet)
1927 my $hold_fill_target = $self->_result->hold_fill_target;
1928 if ($hold_fill_target) {
1929 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1932 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1933 # and can't even fake one since the significant columns are nullable.
1934 my $storage = $self->_result->result_source->storage;
1937 my ($storage, $dbh, @cols) = @_;
1939 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1944 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1946 return $to_biblionumber;
1951 my $bundle_items = $item->bundle_items;
1953 Returns the items associated with this bundle
1960 my $rs = $self->_result->bundle_items;
1961 return Koha::Items->_new_from_dbic($rs);
1965 =head3 bundle_items_not_lost
1967 my $bundle_items = $item->bundle_items_not_lost;
1969 Returns the items associated with this bundle that are not lost
1973 sub bundle_items_not_lost {
1975 return $self->bundle_items->search( { itemlost => 0 } );
1978 =head3 bundle_items_lost
1980 my $bundle_items = $item->bundle_items_lost;
1982 Returns the items associated with this bundle that are lost
1986 sub bundle_items_lost {
1988 return $self->bundle_items->search( { itemlost => { '!=' => 0 } } );
1993 my $is_bundle = $item->is_bundle;
1995 Returns whether the item is a bundle or not
2001 return $self->bundle_items->count ? 1 : 0;
2006 my $bundle = $item->bundle_host;
2008 Returns the bundle item this item is attached to
2015 my $bundle_items_rs = $self->_result->item_bundles_item;
2016 return unless $bundle_items_rs;
2017 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
2022 my $in_bundle = $item->in_bundle;
2024 Returns whether this item is currently in a bundle
2030 return $self->bundle_host ? 1 : 0;
2033 =head3 add_to_bundle
2035 my $link = $item->add_to_bundle($bundle_item);
2037 Adds the bundle_item passed to this item
2042 my ( $self, $bundle_item, $options ) = @_;
2046 Koha::Exceptions::Item::Bundle::IsBundle->throw()
2047 if ( $self->itemnumber eq $bundle_item->itemnumber
2048 || $bundle_item->is_bundle
2049 || $self->in_bundle );
2051 my $schema = Koha::Database->new->schema;
2053 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
2059 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
2061 my $checkout = $bundle_item->checkout;
2063 unless ($options->{force_checkin}) {
2064 Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
2067 my $branchcode = C4::Context->userenv->{'branch'};
2068 my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
2070 Koha::Exceptions::Checkin::FailedCheckin->throw();
2074 my $holds = $bundle_item->current_holds;
2075 if ($holds->count) {
2076 unless ($options->{ignore_holds}) {
2077 Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
2081 $self->_result->add_to_item_bundles_hosts(
2082 { item => $bundle_item->itemnumber } );
2084 $bundle_item->notforloan($BundleNotLoanValue)->store();
2090 # 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
2091 if ( ref($_) eq 'DBIx::Class::Exception' ) {
2092 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
2094 # FIXME: MySQL error, if we support more DB engines we should implement this for each
2095 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
2096 Koha::Exceptions::Object::FKConstraint->throw(
2097 error => 'Broken FK constraint',
2098 broken_fk => $+{column}
2103 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
2105 Koha::Exceptions::Object::DuplicateID->throw(
2106 error => 'Duplicate ID',
2107 duplicate_id => $+{key}
2110 elsif ( $_->{msg} =~
2111 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
2113 { # The optional \W in the regex might be a quote or backtick
2114 my $type = $+{type};
2115 my $value = $+{value};
2116 my $property = $+{property};
2117 $property =~ s/['`]//g;
2118 Koha::Exceptions::Object::BadValue->throw(
2121 property => $property =~ /(\w+\.\w+)$/
2124 , # results in table.column without quotes or backtics
2128 # Catch-all for foreign key breakages. It will help find other use cases
2137 =head3 remove_from_bundle
2139 Remove this item from any bundle it may have been attached to.
2143 sub remove_from_bundle {
2146 my $bundle_host = $self->bundle_host;
2148 return 0 unless $bundle_host; # Should not we raise an exception here?
2150 Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $bundle_host->checkout;
2152 my $bundle_item_rs = $self->_result->item_bundles_item;
2153 if ( $bundle_item_rs ) {
2154 $bundle_item_rs->delete;
2155 $self->notforloan(0)->store();
2161 =head2 Internal methods
2163 =head3 _after_item_action_hooks
2165 Helper method that takes care of calling all plugin hooks
2169 sub _after_item_action_hooks {
2170 my ( $self, $params ) = @_;
2172 my $action = $params->{action};
2174 Koha::Plugins->call(
2175 'after_item_action',
2178 item => $self, #FIXME To be deprecated
2179 item_id => $self->itemnumber, #FIXME To be deprecated
2180 payload => { item => $self, item_id => $self->itemnumber },
2187 my $recall = $item->recall;
2189 Return the relevant recall for this item
2195 my @recalls = Koha::Recalls->search(
2197 biblio_id => $self->biblionumber,
2200 { order_by => { -asc => 'created_date' } }
2203 my $item_level_recall;
2204 foreach my $recall (@recalls) {
2205 if ( $recall->item_level ) {
2206 $item_level_recall = 1;
2207 if ( $recall->item_id == $self->itemnumber ) {
2212 if ($item_level_recall) {
2214 # recall needs to be filled be a specific item only
2215 # no other item is relevant to return
2219 # no item-level recall to return, so return earliest biblio-level
2220 # FIXME: eventually this will be based on priority
2224 =head3 can_be_recalled
2226 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
2228 Does item-level checks and returns if items can be recalled by this borrower
2232 sub can_be_recalled {
2233 my ( $self, $params ) = @_;
2235 return 0 if !( C4::Context->preference('UseRecalls') );
2237 # check if this item is not for loan, withdrawn or lost
2238 return 0 if ( $self->notforloan != 0 );
2239 return 0 if ( $self->itemlost != 0 );
2240 return 0 if ( $self->withdrawn != 0 );
2242 # check if this item is not checked out - if not checked out, can't be recalled
2243 return 0 if ( !defined( $self->checkout ) );
2245 my $patron = $params->{patron};
2247 my $branchcode = C4::Context->userenv->{'branch'};
2249 $branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
2252 # Check the circulation rule for each relevant itemtype for this item
2253 my $rule = Koha::CirculationRules->get_effective_rules({
2254 branchcode => $branchcode,
2255 categorycode => $patron ? $patron->categorycode : undef,
2256 itemtype => $self->effective_itemtype,
2259 'recalls_per_record',
2264 # check recalls allowed has been set and is not zero
2265 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2268 # check borrower has not reached open recalls allowed limit
2269 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
2271 # check borrower has not reach open recalls allowed per record limit
2272 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
2274 # check if this patron has already recalled this item
2275 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
2277 # check if this patron has already checked out this item
2278 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2280 # check if this patron has already reserved this item
2281 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
2284 # check item availability
2285 # items are unavailable for recall if they are lost, withdrawn or notforloan
2286 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
2288 # if there are no available items at all, no recall can be placed
2289 return 0 if ( scalar @items == 0 );
2291 my $checked_out_count = 0;
2293 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
2296 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
2297 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
2299 # can't recall if no items have been checked out
2300 return 0 if ( $checked_out_count == 0 );
2306 =head3 can_be_waiting_recall
2308 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
2310 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
2311 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
2315 sub can_be_waiting_recall {
2318 return 0 if !( C4::Context->preference('UseRecalls') );
2320 # check if this item is not for loan, withdrawn or lost
2321 return 0 if ( $self->notforloan != 0 );
2322 return 0 if ( $self->itemlost != 0 );
2323 return 0 if ( $self->withdrawn != 0 );
2325 my $branchcode = $self->holdingbranch;
2326 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
2327 $branchcode = C4::Context->userenv->{'branch'};
2329 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
2332 # Check the circulation rule for each relevant itemtype for this item
2333 my $most_relevant_recall = $self->check_recalls;
2334 my $rule = Koha::CirculationRules->get_effective_rules(
2336 branchcode => $branchcode,
2337 categorycode => $most_relevant_recall ? $most_relevant_recall->patron->categorycode : undef,
2338 itemtype => $self->effective_itemtype,
2339 rules => [ 'recalls_allowed', ],
2343 # check recalls allowed has been set and is not zero
2344 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
2350 =head3 check_recalls
2352 my $recall = $item->check_recalls;
2354 Get the most relevant recall for this item.
2361 my @recalls = Koha::Recalls->search(
2362 { biblio_id => $self->biblionumber,
2363 item_id => [ $self->itemnumber, undef ]
2365 { order_by => { -asc => 'created_date' } }
2366 )->filter_by_current->as_list;
2369 # iterate through relevant recalls to find the best one.
2370 # if we come across a waiting recall, use this one.
2371 # 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.
2372 foreach my $r ( @recalls ) {
2373 if ( $r->waiting ) {
2378 unless ( defined $recall ) {
2379 $recall = $recalls[0];
2385 =head3 is_notforloan
2387 my $is_notforloan = $item->is_notforloan;
2389 Determine whether or not this item is "notforloan" based on
2390 the item's notforloan status or its item type
2396 my $is_notforloan = 0;
2398 if ( $self->notforloan ){
2402 my $itemtype = $self->itemtype;
2404 if ( $itemtype->notforloan ){
2410 return $is_notforloan;
2413 =head3 is_denied_renewal
2415 my $is_denied_renewal = $item->is_denied_renewal;
2417 Determine whether or not this item can be renewed based on the
2418 rules set in the ItemsDeniedRenewal system preference.
2422 sub is_denied_renewal {
2424 my $denyingrules = C4::Context->yaml_preference('ItemsDeniedRenewal');
2425 return 0 unless $denyingrules;
2426 foreach my $field (keys %$denyingrules) {
2427 # Silently ignore bad column names; TODO we should validate elsewhere
2428 next if !$self->_result->result_source->has_column($field);
2429 my $val = $self->$field;
2430 if( !defined $val) {
2431 if ( any { !defined $_ } @{$denyingrules->{$field}} ){
2434 } elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
2435 # If the results matches the values in the syspref
2436 # We return true if match found
2443 =head3 analytics_count
2445 my $analytics_count = $item->analytics_count;
2447 Return the related analytic records count.
2449 It returns 0 if I<EasyAnalyticalRecords> is disabled.
2453 sub analytics_count {
2455 return C4::Items::GetAnalyticsCount($self->itemnumber);
2460 Returns a map of column name to string representations including the string,
2461 the mapping type and the mapping category where appropriate.
2463 Currently handles authorised value mappings, library, callnumber and itemtype
2466 Accepts a param hashref where the 'public' key denotes whether we want the public
2467 or staff client strings.
2472 my ( $self, $params ) = @_;
2473 my $frameworkcode = C4::Biblio::GetFrameworkCode($self->biblionumber);
2474 my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 } );
2475 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
2477 my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
2479 # Hardcoded known 'authorised_value' values mapped to API codes
2480 my $code_to_type = {
2481 branches => 'library',
2482 cn_source => 'call_number_source',
2483 itemtypes => 'item_type',
2486 # Handle not null and default values for integers and dates
2489 foreach my $col ( @{$self->_columns} ) {
2491 # By now, we are done with known columns, now check the framework for mappings
2492 my $field = $self->_result->result_source->name . '.' . $col;
2494 # Check there's an entry in the MARC subfield structure for the field
2495 if ( exists $mss->{$field}
2496 && scalar @{ $mss->{$field} } > 0
2497 && $mss->{$field}[0]->{authorised_value} )
2499 my $subfield = $mss->{$field}[0];
2500 my $code = $subfield->{authorised_value};
2502 my $str = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
2503 my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
2504 $strings->{$col} = {
2507 ( $type eq 'av' ? ( category => $code ) : () ),
2516 =head3 location_update_trigger
2518 $item->location_update_trigger( $action );
2520 Updates the item location based on I<$action>. It is done like this:
2524 =item For B<checkin>, location is updated following the I<UpdateItemLocationOnCheckin> preference.
2526 =item For B<checkout>, location is updated following the I<UpdateItemLocationOnCheckout> preference.
2530 FIXME: It should return I<$self>. See bug 35270.
2534 sub location_update_trigger {
2535 my ( $self, $action ) = @_;
2537 my ( $update_loc_rules, $messages );
2538 if ( $action eq 'checkin' ) {
2539 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckin');
2541 $update_loc_rules = C4::Context->yaml_preference('UpdateItemLocationOnCheckout');
2544 if ($update_loc_rules) {
2545 if ( defined $update_loc_rules->{_ALL_} ) {
2546 if ( $update_loc_rules->{_ALL_} eq '_PERM_' ) {
2547 $update_loc_rules->{_ALL_} = $self->permanent_location;
2549 if ( $update_loc_rules->{_ALL_} eq '_BLANK_' ) {
2550 $update_loc_rules->{_ALL_} = '';
2553 ( defined $self->location && $self->location ne $update_loc_rules->{_ALL_} )
2554 || ( !defined $self->location
2555 && $update_loc_rules->{_ALL_} ne "" )
2558 $messages->{'ItemLocationUpdated'} =
2559 { from => $self->location, to => $update_loc_rules->{_ALL_} };
2560 $self->location( $update_loc_rules->{_ALL_} )->store(
2563 skip_record_index => 1,
2564 skip_holds_queue => 1
2569 foreach my $key ( keys %$update_loc_rules ) {
2570 if ( $update_loc_rules->{$key} eq '_PERM_' ) {
2571 $update_loc_rules->{$key} = $self->permanent_location;
2572 } elsif ( $update_loc_rules->{$key} eq '_BLANK_' ) {
2573 $update_loc_rules->{$key} = '';
2577 defined $self->location
2578 && $self->location eq $key
2579 && $self->location ne $update_loc_rules->{$key}
2581 || ( $key eq '_BLANK_'
2582 && ( !defined $self->location || $self->location eq '' )
2583 && $update_loc_rules->{$key} ne '' )
2586 $messages->{'ItemLocationUpdated'} = {
2587 from => $self->location,
2588 to => $update_loc_rules->{$key}
2590 $self->location( $update_loc_rules->{$key} )->store(
2593 skip_record_index => 1,
2594 skip_holds_queue => 1
2615 Kyle M Hall <kyle@bywatersolutions.com>