3 # Copyright ByWater Solutions 2014
5 # This file is part of Koha.
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
22 use List::MoreUtils qw( any );
23 use Try::Tiny qw( catch try );
26 use Koha::DateUtils qw( dt_from_string output_pref );
29 use C4::Circulation qw( barcodedecode GetBranchItemRule );
31 use C4::ClassSource qw( GetClassSort );
32 use C4::Log qw( logaction );
34 use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue;
35 use Koha::Biblio::ItemGroups;
37 use Koha::CirculationRules;
38 use Koha::CoverImages;
39 use Koha::Exceptions::Item::Transfer;
40 use Koha::Item::Attributes;
41 use Koha::Item::Transfer::Limits;
42 use Koha::Item::Transfers;
47 use Koha::Result::Boolean;
48 use Koha::SearchEngine::Indexer;
49 use Koha::StockRotationItem;
50 use Koha::StockRotationRotas;
51 use Koha::TrackedLinks;
53 use base qw(Koha::Object);
57 Koha::Item - Koha Item object class
69 $params can take an optional 'skip_record_index' parameter.
70 If set, the reindexation process will not happen (index_records not called)
72 NOTE: This is a temporary fix to answer a performance issue when lot of items
73 are added (or modified) at the same time.
74 The correct way to fix this is to make the ES reindexation process async.
75 You should not turn it on if you do not understand what it is doing exactly.
81 my $params = @_ ? shift : {};
83 my $log_action = $params->{log_action} // 1;
85 # We do not want to oblige callers to pass this value
86 # Dev conveniences vs performance?
87 unless ( $self->biblioitemnumber ) {
88 $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
91 # See related changes from C4::Items::AddItem
92 unless ( $self->itype ) {
93 $self->itype($self->biblio->biblioitem->itemtype);
96 $self->barcode( C4::Circulation::barcodedecode( $self->barcode ) );
98 my $today = dt_from_string;
99 my $action = 'create';
101 unless ( $self->in_storage ) { #AddItem
103 unless ( $self->permanent_location ) {
104 $self->permanent_location($self->location);
107 my $default_location = C4::Context->preference('NewItemsDefaultLocation');
108 unless ( $self->location || !$default_location ) {
109 $self->permanent_location( $self->location || $default_location )
110 unless $self->permanent_location;
111 $self->location($default_location);
114 unless ( $self->replacementpricedate ) {
115 $self->replacementpricedate($today);
117 unless ( $self->datelastseen ) {
118 $self->datelastseen($today);
121 unless ( $self->dateaccessioned ) {
122 $self->dateaccessioned($today);
125 if ( $self->itemcallnumber
126 or $self->cn_source )
128 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
129 $self->cn_sort($cn_sort);
136 my %updated_columns = $self->_result->get_dirty_columns;
137 return $self->SUPER::store unless %updated_columns;
139 # Retrieve the item for comparison if we need to
141 exists $updated_columns{itemlost}
142 or exists $updated_columns{withdrawn}
143 or exists $updated_columns{damaged}
144 ) ? $self->get_from_storage : undef;
146 # Update *_on fields if needed
147 # FIXME: Why not for AddItem as well?
148 my @fields = qw( itemlost withdrawn damaged );
149 for my $field (@fields) {
151 # If the field is defined but empty or 0, we are
152 # removing/unsetting and thus need to clear out
154 if ( exists $updated_columns{$field}
155 && defined( $self->$field )
158 my $field_on = "${field}_on";
159 $self->$field_on(undef);
161 # If the field has changed otherwise, we much update
163 elsif (exists $updated_columns{$field}
164 && $updated_columns{$field}
165 && !$pre_mod_item->$field )
167 my $field_on = "${field}_on";
169 DateTime::Format::MySQL->format_datetime(
176 if ( exists $updated_columns{itemcallnumber}
177 or exists $updated_columns{cn_source} )
179 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
180 $self->cn_sort($cn_sort);
184 if ( exists $updated_columns{location}
185 and $self->location ne 'CART'
186 and $self->location ne 'PROC'
187 and not exists $updated_columns{permanent_location} )
189 $self->permanent_location( $self->location );
192 # If item was lost and has now been found,
193 # reverse any list item charges if necessary.
194 if ( exists $updated_columns{itemlost}
195 and $updated_columns{itemlost} <= 0
196 and $pre_mod_item->itemlost > 0 )
198 $self->_set_found_trigger($pre_mod_item);
203 my $result = $self->SUPER::store;
204 if ( $log_action && C4::Context->preference("CataloguingLog") ) {
206 ? logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
207 : logaction( "CATALOGUING", "MODIFY", $self->itemnumber, $self );
209 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
210 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
211 unless $params->{skip_record_index};
212 $self->get_from_storage->_after_item_action_hooks({ action => $action });
214 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
216 biblio_ids => [ $self->biblionumber ]
218 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
229 my $params = @_ ? shift : {};
231 # FIXME check the item has no current issues
232 # i.e. raise the appropriate exception
234 # Get the item group so we can delete it later if it has no items left
235 my $item_group = C4::Context->preference('EnableItemGroups') ? $self->item_group : undef;
237 my $result = $self->SUPER::delete;
239 # Delete the item gorup if it has no items left
240 $item_group->delete if ( $item_group && $item_group->items->count == 0 );
242 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
243 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
244 unless $params->{skip_record_index};
246 $self->_after_item_action_hooks({ action => 'delete' });
248 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
249 if C4::Context->preference("CataloguingLog");
251 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
253 biblio_ids => [ $self->biblionumber ]
255 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
266 my $params = @_ ? shift : {};
268 my $safe_to_delete = $self->safe_to_delete;
269 return $safe_to_delete unless $safe_to_delete;
271 $self->move_to_deleted;
273 return $self->delete($params);
276 =head3 safe_to_delete
278 returns 1 if the item is safe to delete,
280 "book_on_loan" if the item is checked out,
282 "not_same_branch" if the item is blocked by independent branches,
284 "book_reserved" if the there are holds aganst the item, or
286 "linked_analytics" if the item has linked analytic records.
288 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
297 $error = "book_on_loan" if $self->checkout;
299 $error = "not_same_branch"
300 if defined C4::Context->userenv
301 and !C4::Context->IsSuperLibrarian()
302 and C4::Context->preference("IndependentBranches")
303 and ( C4::Context->userenv->{branch} ne $self->homebranch );
305 # check it doesn't have a waiting reserve
306 $error = "book_reserved"
307 if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
309 $error = "linked_analytics"
310 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
312 $error = "last_item_for_hold"
313 if $self->biblio->items->count == 1
314 && $self->biblio->holds->search(
321 return Koha::Result::Boolean->new(0)->add_message({ message => $error });
324 return Koha::Result::Boolean->new(1);
327 =head3 move_to_deleted
329 my $is_moved = $item->move_to_deleted;
331 Move an item to the deleteditems table.
332 This can be done before deleting an item, to make sure the data are not completely deleted.
336 sub move_to_deleted {
338 my $item_infos = $self->unblessed;
339 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
340 $item_infos->{deleted_on} = dt_from_string;
341 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
345 =head3 effective_itemtype
347 Returns the itemtype for the item based on whether item level itemtypes are set or not.
351 sub effective_itemtype {
354 return $self->_result()->effective_itemtype();
364 $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
366 return $self->{_home_branch};
369 =head3 holding_branch
376 $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
378 return $self->{_holding_branch};
383 my $biblio = $item->biblio;
385 Return the bibliographic record of this item
391 my $biblio_rs = $self->_result->biblio;
392 return Koha::Biblio->_new_from_dbic( $biblio_rs );
397 my $biblioitem = $item->biblioitem;
399 Return the biblioitem record of this item
405 my $biblioitem_rs = $self->_result->biblioitem;
406 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
411 my $checkout = $item->checkout;
413 Return the checkout for this item
419 my $checkout_rs = $self->_result->issue;
420 return unless $checkout_rs;
421 return Koha::Checkout->_new_from_dbic( $checkout_rs );
426 my $item_group = $item->item_group;
428 Return the item group for this item
435 my $item_group_item = $self->_result->item_group_item;
436 return unless $item_group_item;
438 my $item_group_rs = $item_group_item->item_group;
439 return unless $item_group_rs;
441 my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs );
447 my $return_claims = $item->return_claims;
449 Return any return_claims associated with this item
454 my ( $self, $params, $attrs ) = @_;
455 my $claims_rs = $self->_result->return_claims->search($params, $attrs);
456 return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs );
461 my $return_claim = $item->return_claim;
463 Returns the most recent unresolved return_claims associated with this item
470 $self->_result->return_claims->search( { resolution => undef },
471 { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single;
472 return unless $claims_rs;
473 return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs);
478 my $holds = $item->holds();
479 my $holds = $item->holds($params);
480 my $holds = $item->holds({ found => 'W'});
482 Return holds attached to an item, optionally accept a hashref of params to pass to search
487 my ( $self,$params ) = @_;
488 my $holds_rs = $self->_result->reserves->search($params);
489 return Koha::Holds->_new_from_dbic( $holds_rs );
492 =head3 request_transfer
494 my $transfer = $item->request_transfer(
498 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
502 Add a transfer request for this item to the given branch for the given reason.
504 An exception will be thrown if the BranchTransferLimits would prevent the requested
505 transfer, unless 'ignore_limits' is passed to override the limits.
507 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
508 The caller should catch such cases and retry the transfer request as appropriate passing
509 an appropriate override.
512 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
513 * replace - Used to replace the existing transfer request with your own.
517 sub request_transfer {
518 my ( $self, $params ) = @_;
520 # check for mandatory params
521 my @mandatory = ( 'to', 'reason' );
522 for my $param (@mandatory) {
523 unless ( defined( $params->{$param} ) ) {
524 Koha::Exceptions::MissingParameter->throw(
525 error => "The $param parameter is mandatory" );
529 Koha::Exceptions::Item::Transfer::Limit->throw()
530 unless ( $params->{ignore_limits}
531 || $self->can_be_transferred( { to => $params->{to} } ) );
533 my $request = $self->get_transfer;
534 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
535 if ( $request && !$params->{enqueue} && !$params->{replace} );
537 $request->cancel( { reason => $params->{reason}, force => 1 } )
538 if ( defined($request) && $params->{replace} );
540 my $transfer = Koha::Item::Transfer->new(
542 itemnumber => $self->itemnumber,
543 daterequested => dt_from_string,
544 frombranch => $self->holdingbranch,
545 tobranch => $params->{to}->branchcode,
546 reason => $params->{reason},
547 comments => $params->{comment}
556 my $transfer = $item->get_transfer;
558 Return the active transfer request or undef
560 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
561 whereby the most recently sent, but not received, transfer will be returned
562 if it exists, otherwise the oldest unsatisfied transfer will be returned.
564 This allows for transfers to queue, which is the case for stock rotation and
565 rotating collections where a manual transfer may need to take precedence but
566 we still expect the item to end up at a final location eventually.
572 my $transfer_rs = $self->_result->branchtransfers->search(
574 datearrived => undef,
575 datecancelled => undef
579 [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
583 return unless $transfer_rs;
584 return Koha::Item::Transfer->_new_from_dbic($transfer_rs);
589 my $transfer = $item->get_transfers;
591 Return the list of outstanding transfers (i.e requested but not yet cancelled
594 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
595 whereby the most recently sent, but not received, transfer will be returned
596 first if it exists, otherwise requests are in oldest to newest request order.
598 This allows for transfers to queue, which is the case for stock rotation and
599 rotating collections where a manual transfer may need to take precedence but
600 we still expect the item to end up at a final location eventually.
606 my $transfer_rs = $self->_result->branchtransfers->search(
608 datearrived => undef,
609 datecancelled => undef
613 [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
616 return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
619 =head3 last_returned_by
621 Gets and sets the last borrower to return an item.
623 Accepts and returns Koha::Patron objects
625 $item->last_returned_by( $borrowernumber );
627 $last_returned_by = $item->last_returned_by();
631 sub last_returned_by {
632 my ( $self, $borrower ) = @_;
634 my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
637 return $items_last_returned_by_rs->update_or_create(
638 { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
641 unless ( $self->{_last_returned_by} ) {
642 my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
644 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
648 return $self->{_last_returned_by};
652 =head3 can_article_request
654 my $bool = $item->can_article_request( $borrower )
656 Returns true if item can be specifically requested
658 $borrower must be a Koha::Patron object
662 sub can_article_request {
663 my ( $self, $borrower ) = @_;
665 my $rule = $self->article_request_type($borrower);
667 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
671 =head3 hidden_in_opac
673 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
675 Returns true if item fields match the hidding criteria defined in $rules.
676 Returns false otherwise.
678 Takes HASHref that can have the following parameters:
680 $rules : { <field> => [ value_1, ... ], ... }
682 Note: $rules inherits its structure from the parsed YAML from reading
683 the I<OpacHiddenItems> system preference.
688 my ( $self, $params ) = @_;
690 my $rules = $params->{rules} // {};
693 if C4::Context->preference('hidelostitems') and
696 my $hidden_in_opac = 0;
698 foreach my $field ( keys %{$rules} ) {
700 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
706 return $hidden_in_opac;
709 =head3 can_be_transferred
711 $item->can_be_transferred({ to => $to_library, from => $from_library })
712 Checks if an item can be transferred to given library.
714 This feature is controlled by two system preferences:
715 UseBranchTransferLimits to enable / disable the feature
716 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
717 for setting the limitations
719 Takes HASHref that can have the following parameters:
720 MANDATORY PARAMETERS:
723 $from : Koha::Library # if not given, item holdingbranch
724 # will be used instead
726 Returns 1 if item can be transferred to $to_library, otherwise 0.
728 To find out whether at least one item of a Koha::Biblio can be transferred, please
729 see Koha::Biblio->can_be_transferred() instead of using this method for
730 multiple items of the same biblio.
734 sub can_be_transferred {
735 my ($self, $params) = @_;
737 my $to = $params->{to};
738 my $from = $params->{from};
740 $to = $to->branchcode;
741 $from = defined $from ? $from->branchcode : $self->holdingbranch;
743 return 1 if $from eq $to; # Transfer to current branch is allowed
744 return 1 unless C4::Context->preference('UseBranchTransferLimits');
746 my $limittype = C4::Context->preference('BranchTransferLimitsType');
747 return Koha::Item::Transfer::Limits->search({
750 $limittype => $limittype eq 'itemtype'
751 ? $self->effective_itemtype : $self->ccode
756 =head3 pickup_locations
758 $pickup_locations = $item->pickup_locations( {patron => $patron } )
760 Returns possible pickup locations for this item, according to patron's home library (if patron is defined and holds are allowed only from hold groups)
761 and if item can be transferred to each pickup location.
765 sub pickup_locations {
766 my ($self, $params) = @_;
768 my $patron = $params->{patron};
770 my $circ_control_branch =
771 C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
773 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
775 if(defined $patron) {
776 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
777 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
780 my $pickup_libraries = Koha::Libraries->search();
781 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
782 $pickup_libraries = $self->home_branch->get_hold_libraries;
783 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
784 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
785 $pickup_libraries = $plib->get_hold_libraries;
786 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
787 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
788 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
789 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
792 return $pickup_libraries->search(
797 order_by => ['branchname']
799 ) unless C4::Context->preference('UseBranchTransferLimits');
801 my $limittype = C4::Context->preference('BranchTransferLimitsType');
802 my ($ccode, $itype) = (undef, undef);
803 if( $limittype eq 'ccode' ){
804 $ccode = $self->ccode;
806 $itype = $self->itype;
808 my $limits = Koha::Item::Transfer::Limits->search(
810 fromBranch => $self->holdingbranch,
814 { columns => ['toBranch'] }
817 return $pickup_libraries->search(
819 pickup_location => 1,
821 '-not_in' => $limits->_resultset->as_query
825 order_by => ['branchname']
830 =head3 article_request_type
832 my $type = $item->article_request_type( $borrower )
834 returns 'yes', 'no', 'bib_only', or 'item_only'
836 $borrower must be a Koha::Patron object
840 sub article_request_type {
841 my ( $self, $borrower ) = @_;
843 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
845 $branch_control eq 'homebranch' ? $self->homebranch
846 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
848 my $borrowertype = $borrower->categorycode;
849 my $itemtype = $self->effective_itemtype();
850 my $rule = Koha::CirculationRules->get_effective_rule(
852 rule_name => 'article_requests',
853 categorycode => $borrowertype,
854 itemtype => $itemtype,
855 branchcode => $branchcode
859 return q{} unless $rule;
860 return $rule->rule_value || q{}
869 my $attributes = { order_by => 'priority' };
870 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
872 itemnumber => $self->itemnumber,
875 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
876 waitingdate => { '!=' => undef },
879 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
880 return Koha::Holds->_new_from_dbic($hold_rs);
883 =head3 stockrotationitem
885 my $sritem = Koha::Item->stockrotationitem;
887 Returns the stock rotation item associated with the current item.
891 sub stockrotationitem {
893 my $rs = $self->_result->stockrotationitem;
895 return Koha::StockRotationItem->_new_from_dbic( $rs );
900 my $item = $item->add_to_rota($rota_id);
902 Add this item to the rota identified by $ROTA_ID, which means associating it
903 with the first stage of that rota. Should this item already be associated
904 with a rota, then we will move it to the new rota.
909 my ( $self, $rota_id ) = @_;
910 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
914 =head3 has_pending_hold
916 my $is_pending_hold = $item->has_pending_hold();
918 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
922 sub has_pending_hold {
924 my $pending_hold = $self->_result->tmp_holdsqueues;
925 return $pending_hold->count ? 1: 0;
930 my $field = $item->as_marc_field;
932 This method returns a MARC::Field object representing the Koha::Item object
933 with the current mappings configuration.
940 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
942 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
946 my $item_field = $tagslib->{$itemtag};
948 my $more_subfields = $self->additional_attributes->to_hashref;
949 foreach my $subfield (
951 $a->{display_order} <=> $b->{display_order}
952 || $a->{subfield} cmp $b->{subfield}
953 } grep { ref($_) && %$_ } values %$item_field
956 my $kohafield = $subfield->{kohafield};
957 my $tagsubfield = $subfield->{tagsubfield};
959 if ( defined $kohafield ) {
960 next if $kohafield !~ m{^items\.}; # That would be weird!
961 ( my $attribute = $kohafield ) =~ s|^items\.||;
962 $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
963 if defined $self->$attribute and $self->$attribute ne '';
965 $value = $more_subfields->{$tagsubfield}
968 next unless defined $value
971 if ( $subfield->{repeatable} ) {
972 my @values = split '\|', $value;
973 push @subfields, ( $tagsubfield => $_ ) for @values;
976 push @subfields, ( $tagsubfield => $value );
981 return unless @subfields;
983 return MARC::Field->new(
984 "$itemtag", ' ', ' ', @subfields
988 =head3 renewal_branchcode
990 Returns the branchcode to be recorded in statistics renewal of the item
994 sub renewal_branchcode {
996 my ($self, $params ) = @_;
998 my $interface = C4::Context->interface;
1000 if ( $interface eq 'opac' ){
1001 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1002 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
1003 $branchcode = 'OPACRenew';
1005 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
1006 $branchcode = $self->homebranch;
1008 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1009 $branchcode = $self->checkout->patron->branchcode;
1011 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1012 $branchcode = $self->checkout->branchcode;
1018 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1019 ? C4::Context->userenv->{branch} : $params->{branch};
1026 Return the cover images associated with this item.
1033 my $cover_image_rs = $self->_result->cover_images;
1034 return unless $cover_image_rs;
1035 return Koha::CoverImages->_new_from_dbic($cover_image_rs);
1038 =head3 columns_to_str
1040 my $values = $items->columns_to_str;
1042 Return a hashref with the string representation of the different attribute of the item.
1044 This is meant to be used for display purpose only.
1048 sub columns_to_str {
1051 my $frameworkcode = $self->biblio->frameworkcode;
1052 my $tagslib = C4::Biblio::GetMarcStructure(1, $frameworkcode);
1053 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1055 my $columns_info = $self->_result->result_source->columns_info;
1057 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1059 for my $column ( keys %$columns_info ) {
1061 next if $column eq 'more_subfields_xml';
1063 my $value = $self->$column;
1064 # 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
1066 if ( not defined $value or $value eq "" ) {
1067 $values->{$column} = $value;
1072 exists $mss->{"items.$column"}
1073 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1076 $values->{$column} =
1078 ? $subfield->{authorised_value}
1079 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1080 $subfield->{tagsubfield}, $value, '', $tagslib )
1086 $self->more_subfields_xml
1087 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1092 my ( $field ) = $marc_more->fields;
1093 for my $sf ( $field->subfields ) {
1094 my $subfield_code = $sf->[0];
1095 my $value = $sf->[1];
1096 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1097 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1099 $subfield->{authorised_value}
1100 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1101 $subfield->{tagsubfield}, $value, '', $tagslib )
1104 push @{$more_values->{$subfield_code}}, $value;
1107 while ( my ( $k, $v ) = each %$more_values ) {
1108 $values->{$k} = join ' | ', @$v;
1115 =head3 additional_attributes
1117 my $attributes = $item->additional_attributes;
1118 $attributes->{k} = 'new k';
1119 $item->update({ more_subfields => $attributes->to_marcxml });
1121 Returns a Koha::Item::Attributes object that represents the non-mapped
1122 attributes for this item.
1126 sub additional_attributes {
1129 return Koha::Item::Attributes->new_from_marcxml(
1130 $self->more_subfields_xml,
1134 =head3 _set_found_trigger
1136 $self->_set_found_trigger
1138 Finds the most recent lost item charge for this item and refunds the patron
1139 appropriately, taking into account any payments or writeoffs already applied
1142 Internal function, not exported, called only by Koha::Item->store.
1146 sub _set_found_trigger {
1147 my ( $self, $pre_mod_item ) = @_;
1149 # Reverse any lost item charges if necessary.
1150 my $no_refund_after_days =
1151 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1152 if ($no_refund_after_days) {
1153 my $today = dt_from_string();
1154 my $lost_age_in_days =
1155 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1158 return $self unless $lost_age_in_days < $no_refund_after_days;
1161 my $lostreturn_policy = Koha::CirculationRules->get_lostreturn_policy(
1164 return_branch => C4::Context->userenv
1165 ? C4::Context->userenv->{'branch'}
1170 if ( $lostreturn_policy ) {
1172 # refund charge made for lost book
1173 my $lost_charge = Koha::Account::Lines->search(
1175 itemnumber => $self->itemnumber,
1176 debit_type_code => 'LOST',
1177 status => [ undef, { '<>' => 'FOUND' } ]
1180 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1185 if ( $lost_charge ) {
1187 my $patron = $lost_charge->patron;
1190 my $account = $patron->account;
1191 my $total_to_refund = 0;
1194 if ( $lost_charge->amount > $lost_charge->amountoutstanding ) {
1196 # some amount has been cancelled. collect the offsets that are not writeoffs
1197 # this works because the only way to subtract from this kind of a debt is
1198 # using the UI buttons 'Pay' and 'Write off'
1199 my $credit_offsets = $lost_charge->debit_offsets(
1201 'credit_id' => { '!=' => undef },
1202 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1204 { join => 'credit' }
1207 $total_to_refund = ( $credit_offsets->count > 0 )
1208 ? $credit_offsets->total * -1 # credits are negative on the DB
1212 my $credit_total = $lost_charge->amountoutstanding + $total_to_refund;
1215 if ( $credit_total > 0 ) {
1217 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1218 $credit = $account->add_credit(
1220 amount => $credit_total,
1221 description => 'Item found ' . $self->itemnumber,
1222 type => 'LOST_FOUND',
1223 interface => C4::Context->interface,
1224 library_id => $branchcode,
1225 item_id => $self->itemnumber,
1226 issue_id => $lost_charge->issue_id
1230 $credit->apply( { debits => [$lost_charge] } );
1234 message => 'lost_refunded',
1235 payload => { credit_id => $credit->id }
1240 # Update the account status
1241 $lost_charge->status('FOUND');
1242 $lost_charge->store();
1244 # Reconcile balances if required
1245 if ( C4::Context->preference('AccountAutoReconcile') ) {
1246 $account->reconcile_balance;
1251 # restore fine for lost book
1252 if ( $lostreturn_policy eq 'restore' ) {
1253 my $lost_overdue = Koha::Account::Lines->search(
1255 itemnumber => $self->itemnumber,
1256 debit_type_code => 'OVERDUE',
1260 order_by => { '-desc' => 'date' },
1265 if ( $lost_overdue ) {
1267 my $patron = $lost_overdue->patron;
1269 my $account = $patron->account;
1271 # Update status of fine
1272 $lost_overdue->status('FOUND')->store();
1274 # Find related forgive credit
1275 my $refund = $lost_overdue->credits(
1277 credit_type_code => 'FORGIVEN',
1278 itemnumber => $self->itemnumber,
1279 status => [ { '!=' => 'VOID' }, undef ]
1281 { order_by => { '-desc' => 'date' }, rows => 1 }
1285 # Revert the forgive credit
1286 $refund->void({ interface => 'trigger' });
1290 message => 'lost_restored',
1291 payload => { refund_id => $refund->id }
1296 # Reconcile balances if required
1297 if ( C4::Context->preference('AccountAutoReconcile') ) {
1298 $account->reconcile_balance;
1302 } elsif ( $lostreturn_policy eq 'charge' ) {
1306 message => 'lost_charge',
1315 =head3 public_read_list
1317 This method returns the list of publicly readable database fields for both API and UI output purposes
1321 sub public_read_list {
1323 'itemnumber', 'biblionumber', 'homebranch',
1324 'holdingbranch', 'location', 'collectioncode',
1325 'itemcallnumber', 'copynumber', 'enumchron',
1326 'barcode', 'dateaccessioned', 'itemnotes',
1327 'onloan', 'uri', 'itype',
1328 'notforloan', 'damaged', 'itemlost',
1329 'withdrawn', 'restricted'
1335 Overloaded to_api method to ensure item-level itypes is adhered to.
1340 my ($self, $params) = @_;
1342 my $response = $self->SUPER::to_api($params);
1345 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1346 $overrides->{effective_not_for_loan_status} = $self->notforloan ? $self->notforloan : $self->itemtype->notforloan;
1348 return { %$response, %$overrides };
1351 =head3 to_api_mapping
1353 This method returns the mapping for representing a Koha::Item object
1358 sub to_api_mapping {
1360 itemnumber => 'item_id',
1361 biblionumber => 'biblio_id',
1362 biblioitemnumber => undef,
1363 barcode => 'external_id',
1364 dateaccessioned => 'acquisition_date',
1365 booksellerid => 'acquisition_source',
1366 homebranch => 'home_library_id',
1367 price => 'purchase_price',
1368 replacementprice => 'replacement_price',
1369 replacementpricedate => 'replacement_price_date',
1370 datelastborrowed => 'last_checkout_date',
1371 datelastseen => 'last_seen_date',
1373 notforloan => 'not_for_loan_status',
1374 damaged => 'damaged_status',
1375 damaged_on => 'damaged_date',
1376 itemlost => 'lost_status',
1377 itemlost_on => 'lost_date',
1378 withdrawn => 'withdrawn',
1379 withdrawn_on => 'withdrawn_date',
1380 itemcallnumber => 'callnumber',
1381 coded_location_qualifier => 'coded_location_qualifier',
1382 issues => 'checkouts_count',
1383 renewals => 'renewals_count',
1384 reserves => 'holds_count',
1385 restricted => 'restricted_status',
1386 itemnotes => 'public_notes',
1387 itemnotes_nonpublic => 'internal_notes',
1388 holdingbranch => 'holding_library_id',
1389 timestamp => 'timestamp',
1390 location => 'location',
1391 permanent_location => 'permanent_location',
1392 onloan => 'checked_out_date',
1393 cn_source => 'call_number_source',
1394 cn_sort => 'call_number_sort',
1395 ccode => 'collection_code',
1396 materials => 'materials_notes',
1398 itype => 'item_type_id',
1399 more_subfields_xml => 'extended_subfields',
1400 enumchron => 'serial_issue_number',
1401 copynumber => 'copy_number',
1402 stocknumber => 'inventory_number',
1403 new_status => 'new_status',
1404 deleted_on => undef,
1410 my $itemtype = $item->itemtype;
1412 Returns Koha object for effective itemtype
1418 return Koha::ItemTypes->find( $self->effective_itemtype );
1423 my $orders = $item->orders();
1425 Returns a Koha::Acquisition::Orders object
1432 my $orders = $self->_result->item_orders;
1433 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1436 =head3 tracked_links
1438 my $tracked_links = $item->tracked_links();
1440 Returns a Koha::TrackedLinks object
1447 my $tracked_links = $self->_result->linktrackers;
1448 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1451 =head3 move_to_biblio
1453 $item->move_to_biblio($to_biblio[, $params]);
1455 Move the item to another biblio and update any references in other tables.
1457 The final optional parameter, C<$params>, is expected to contain the
1458 'skip_record_index' key, which is relayed down to Koha::Item->store.
1459 There it prevents calling index_records, which takes most of the
1460 time in batch adds/deletes. The caller must take care of calling
1461 index_records separately.
1464 skip_record_index => 1|0
1466 Returns undef if the move failed or the biblionumber of the destination record otherwise
1470 sub move_to_biblio {
1471 my ( $self, $to_biblio, $params ) = @_;
1475 return if $self->biblionumber == $to_biblio->biblionumber;
1477 my $from_biblionumber = $self->biblionumber;
1478 my $to_biblionumber = $to_biblio->biblionumber;
1480 # Own biblionumber and biblioitemnumber
1482 biblionumber => $to_biblionumber,
1483 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1484 })->store({ skip_record_index => $params->{skip_record_index} });
1486 unless ($params->{skip_record_index}) {
1487 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1488 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1491 # Acquisition orders
1492 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1495 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1497 # hold_fill_target (there's no Koha object available yet)
1498 my $hold_fill_target = $self->_result->hold_fill_target;
1499 if ($hold_fill_target) {
1500 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1503 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1504 # and can't even fake one since the significant columns are nullable.
1505 my $storage = $self->_result->result_source->storage;
1508 my ($storage, $dbh, @cols) = @_;
1510 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1515 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1517 return $to_biblionumber;
1522 my $bundle_items = $item->bundle_items;
1524 Returns the items associated with this bundle
1531 if ( !$self->{_bundle_items_cached} ) {
1532 my $bundle_items = Koha::Items->search(
1533 { 'item_bundles_item.host' => $self->itemnumber },
1534 { join => 'item_bundles_item' } );
1535 $self->{_bundle_items} = $bundle_items;
1536 $self->{_bundle_items_cached} = 1;
1539 return $self->{_bundle_items};
1544 my $is_bundle = $item->is_bundle;
1546 Returns whether the item is a bundle or not
1552 return $self->bundle_items->count ? 1 : 0;
1557 my $bundle = $item->bundle_host;
1559 Returns the bundle item this item is attached to
1566 my $bundle_items_rs = $self->_result->item_bundles_item;
1567 return unless $bundle_items_rs;
1568 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1573 my $in_bundle = $item->in_bundle;
1575 Returns whether this item is currently in a bundle
1581 return $self->bundle_host ? 1 : 0;
1584 =head3 add_to_bundle
1586 my $link = $item->add_to_bundle($bundle_item);
1588 Adds the bundle_item passed to this item
1593 my ( $self, $bundle_item ) = @_;
1595 my $schema = Koha::Database->new->schema;
1597 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1602 $self->_result->add_to_item_bundles_hosts(
1603 { item => $bundle_item->itemnumber } );
1605 $bundle_item->notforloan($BundleNotLoanValue)->store();
1611 # 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
1612 if ( ref($_) eq 'DBIx::Class::Exception' ) {
1614 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1616 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1617 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1618 Koha::Exceptions::Object::FKConstraint->throw(
1619 error => 'Broken FK constraint',
1620 broken_fk => $+{column}
1625 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
1627 Koha::Exceptions::Object::DuplicateID->throw(
1628 error => 'Duplicate ID',
1629 duplicate_id => $+{key}
1632 elsif ( $_->{msg} =~
1633 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
1635 { # The optional \W in the regex might be a quote or backtick
1636 my $type = $+{type};
1637 my $value = $+{value};
1638 my $property = $+{property};
1639 $property =~ s/['`]//g;
1640 Koha::Exceptions::Object::BadValue->throw(
1643 property => $property =~ /(\w+\.\w+)$/
1646 , # results in table.column without quotes or backtics
1650 # Catch-all for foreign key breakages. It will help find other use cases
1659 =head3 remove_from_bundle
1661 Remove this item from any bundle it may have been attached to.
1665 sub remove_from_bundle {
1668 my $bundle_item_rs = $self->_result->item_bundles_item;
1669 if ( $bundle_item_rs ) {
1670 $bundle_item_rs->delete;
1671 $self->notforloan(0)->store();
1677 =head2 Internal methods
1679 =head3 _after_item_action_hooks
1681 Helper method that takes care of calling all plugin hooks
1685 sub _after_item_action_hooks {
1686 my ( $self, $params ) = @_;
1688 my $action = $params->{action};
1690 Koha::Plugins->call(
1691 'after_item_action',
1695 item_id => $self->itemnumber,
1702 my $recall = $item->recall;
1704 Return the relevant recall for this item
1710 my @recalls = Koha::Recalls->search(
1712 biblio_id => $self->biblionumber,
1715 { order_by => { -asc => 'created_date' } }
1717 foreach my $recall (@recalls) {
1718 if ( $recall->item_level and $recall->item_id == $self->itemnumber ){
1722 # no item-level recall to return, so return earliest biblio-level
1723 # FIXME: eventually this will be based on priority
1727 =head3 can_be_recalled
1729 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
1731 Does item-level checks and returns if items can be recalled by this borrower
1735 sub can_be_recalled {
1736 my ( $self, $params ) = @_;
1738 return 0 if !( C4::Context->preference('UseRecalls') );
1740 # check if this item is not for loan, withdrawn or lost
1741 return 0 if ( $self->notforloan != 0 );
1742 return 0 if ( $self->itemlost != 0 );
1743 return 0 if ( $self->withdrawn != 0 );
1745 # check if this item is not checked out - if not checked out, can't be recalled
1746 return 0 if ( !defined( $self->checkout ) );
1748 my $patron = $params->{patron};
1750 my $branchcode = C4::Context->userenv->{'branch'};
1752 $branchcode = C4::Circulation::_GetCircControlBranch( $self->unblessed, $patron->unblessed );
1755 # Check the circulation rule for each relevant itemtype for this item
1756 my $rule = Koha::CirculationRules->get_effective_rules({
1757 branchcode => $branchcode,
1758 categorycode => $patron ? $patron->categorycode : undef,
1759 itemtype => $self->effective_itemtype,
1762 'recalls_per_record',
1767 # check recalls allowed has been set and is not zero
1768 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
1771 # check borrower has not reached open recalls allowed limit
1772 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
1774 # check borrower has not reach open recalls allowed per record limit
1775 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
1777 # check if this patron has already recalled this item
1778 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
1780 # check if this patron has already checked out this item
1781 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
1783 # check if this patron has already reserved this item
1784 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
1787 # check item availability
1788 # items are unavailable for recall if they are lost, withdrawn or notforloan
1789 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
1791 # if there are no available items at all, no recall can be placed
1792 return 0 if ( scalar @items == 0 );
1794 my $checked_out_count = 0;
1796 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
1799 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
1800 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
1802 # can't recall if no items have been checked out
1803 return 0 if ( $checked_out_count == 0 );
1809 =head3 can_be_waiting_recall
1811 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
1813 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
1814 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
1818 sub can_be_waiting_recall {
1821 return 0 if !( C4::Context->preference('UseRecalls') );
1823 # check if this item is not for loan, withdrawn or lost
1824 return 0 if ( $self->notforloan != 0 );
1825 return 0 if ( $self->itemlost != 0 );
1826 return 0 if ( $self->withdrawn != 0 );
1828 my $branchcode = $self->holdingbranch;
1829 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
1830 $branchcode = C4::Context->userenv->{'branch'};
1832 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
1835 # Check the circulation rule for each relevant itemtype for this item
1836 my $rule = Koha::CirculationRules->get_effective_rules({
1837 branchcode => $branchcode,
1838 categorycode => undef,
1839 itemtype => $self->effective_itemtype,
1845 # check recalls allowed has been set and is not zero
1846 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
1852 =head3 check_recalls
1854 my $recall = $item->check_recalls;
1856 Get the most relevant recall for this item.
1863 my @recalls = Koha::Recalls->search(
1864 { biblio_id => $self->biblionumber,
1865 item_id => [ $self->itemnumber, undef ]
1867 { order_by => { -asc => 'created_date' } }
1868 )->filter_by_current->as_list;
1871 # iterate through relevant recalls to find the best one.
1872 # if we come across a waiting recall, use this one.
1873 # 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.
1874 foreach my $r ( @recalls ) {
1875 if ( $r->waiting ) {
1880 unless ( defined $recall ) {
1881 $recall = $recalls[0];
1887 =head3 is_notforloan
1889 my $is_notforloan = $item->is_notforloan;
1891 Determine whether or not this item is "notforloan" based on
1892 the item's notforloan status or its item type
1898 my $is_notforloan = 0;
1900 if ( $self->notforloan ){
1904 my $itemtype = $self->itemtype;
1906 if ( $itemtype->notforloan ){
1912 return $is_notforloan;
1925 Kyle M Hall <kyle@bywatersolutions.com>