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;
48 use Koha::Result::Boolean;
49 use Koha::SearchEngine::Indexer;
50 use Koha::StockRotationItem;
51 use Koha::StockRotationRotas;
52 use Koha::TrackedLinks;
54 use base qw(Koha::Object);
58 Koha::Item - Koha Item object class
70 $params can take an optional 'skip_record_index' parameter.
71 If set, the reindexation process will not happen (index_records not called)
73 NOTE: This is a temporary fix to answer a performance issue when lot of items
74 are added (or modified) at the same time.
75 The correct way to fix this is to make the ES reindexation process async.
76 You should not turn it on if you do not understand what it is doing exactly.
82 my $params = @_ ? shift : {};
84 my $log_action = $params->{log_action} // 1;
86 # We do not want to oblige callers to pass this value
87 # Dev conveniences vs performance?
88 unless ( $self->biblioitemnumber ) {
89 $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
92 # See related changes from C4::Items::AddItem
93 unless ( $self->itype ) {
94 $self->itype($self->biblio->biblioitem->itemtype);
97 $self->barcode( C4::Circulation::barcodedecode( $self->barcode ) );
99 my $today = dt_from_string;
100 my $action = 'create';
102 unless ( $self->in_storage ) { #AddItem
104 unless ( $self->permanent_location ) {
105 $self->permanent_location($self->location);
108 my $default_location = C4::Context->preference('NewItemsDefaultLocation');
109 unless ( $self->location || !$default_location ) {
110 $self->permanent_location( $self->location || $default_location )
111 unless $self->permanent_location;
112 $self->location($default_location);
115 unless ( $self->replacementpricedate ) {
116 $self->replacementpricedate($today);
118 unless ( $self->datelastseen ) {
119 $self->datelastseen($today);
122 unless ( $self->dateaccessioned ) {
123 $self->dateaccessioned($today);
126 if ( $self->itemcallnumber
127 or $self->cn_source )
129 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
130 $self->cn_sort($cn_sort);
137 my %updated_columns = $self->_result->get_dirty_columns;
138 return $self->SUPER::store unless %updated_columns;
140 # Retrieve the item for comparison if we need to
142 exists $updated_columns{itemlost}
143 or exists $updated_columns{withdrawn}
144 or exists $updated_columns{damaged}
145 ) ? $self->get_from_storage : undef;
147 # Update *_on fields if needed
148 # FIXME: Why not for AddItem as well?
149 my @fields = qw( itemlost withdrawn damaged );
150 for my $field (@fields) {
152 # If the field is defined but empty or 0, we are
153 # removing/unsetting and thus need to clear out
155 if ( exists $updated_columns{$field}
156 && defined( $self->$field )
159 my $field_on = "${field}_on";
160 $self->$field_on(undef);
162 # If the field has changed otherwise, we much update
164 elsif (exists $updated_columns{$field}
165 && $updated_columns{$field}
166 && !$pre_mod_item->$field )
168 my $field_on = "${field}_on";
170 DateTime::Format::MySQL->format_datetime(
177 if ( exists $updated_columns{itemcallnumber}
178 or exists $updated_columns{cn_source} )
180 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
181 $self->cn_sort($cn_sort);
185 if ( exists $updated_columns{location}
186 and $self->location ne 'CART'
187 and $self->location ne 'PROC'
188 and not exists $updated_columns{permanent_location} )
190 $self->permanent_location( $self->location );
193 # If item was lost and has now been found,
194 # reverse any list item charges if necessary.
195 if ( exists $updated_columns{itemlost}
196 and $updated_columns{itemlost} <= 0
197 and $pre_mod_item->itemlost > 0 )
199 $self->_set_found_trigger($pre_mod_item);
204 my $result = $self->SUPER::store;
205 if ( $log_action && C4::Context->preference("CataloguingLog") ) {
207 ? logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
208 : logaction( "CATALOGUING", "MODIFY", $self->itemnumber, $self );
210 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
211 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
212 unless $params->{skip_record_index};
213 $self->get_from_storage->_after_item_action_hooks({ action => $action });
215 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
217 biblio_ids => [ $self->biblionumber ]
219 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
230 my $params = @_ ? shift : {};
232 # FIXME check the item has no current issues
233 # i.e. raise the appropriate exception
235 # Get the item group so we can delete it later if it has no items left
236 my $item_group = C4::Context->preference('EnableItemGroups') ? $self->item_group : undef;
238 my $result = $self->SUPER::delete;
240 # Delete the item gorup if it has no items left
241 $item_group->delete if ( $item_group && $item_group->items->count == 0 );
243 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
244 $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
245 unless $params->{skip_record_index};
247 $self->_after_item_action_hooks({ action => 'delete' });
249 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
250 if C4::Context->preference("CataloguingLog");
252 Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
254 biblio_ids => [ $self->biblionumber ]
256 ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
267 my $params = @_ ? shift : {};
269 my $safe_to_delete = $self->safe_to_delete;
270 return $safe_to_delete unless $safe_to_delete;
272 $self->move_to_deleted;
274 return $self->delete($params);
277 =head3 safe_to_delete
279 returns 1 if the item is safe to delete,
281 "book_on_loan" if the item is checked out,
283 "not_same_branch" if the item is blocked by independent branches,
285 "book_reserved" if the there are holds aganst the item, or
287 "linked_analytics" if the item has linked analytic records.
289 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
298 $error = "book_on_loan" if $self->checkout;
300 $error = "not_same_branch"
301 if defined C4::Context->userenv
302 and !C4::Context->IsSuperLibrarian()
303 and C4::Context->preference("IndependentBranches")
304 and ( C4::Context->userenv->{branch} ne $self->homebranch );
306 # check it doesn't have a waiting reserve
307 $error = "book_reserved"
308 if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
310 $error = "linked_analytics"
311 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
313 $error = "last_item_for_hold"
314 if $self->biblio->items->count == 1
315 && $self->biblio->holds->search(
322 return Koha::Result::Boolean->new(0)->add_message({ message => $error });
325 return Koha::Result::Boolean->new(1);
328 =head3 move_to_deleted
330 my $is_moved = $item->move_to_deleted;
332 Move an item to the deleteditems table.
333 This can be done before deleting an item, to make sure the data are not completely deleted.
337 sub move_to_deleted {
339 my $item_infos = $self->unblessed;
340 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
341 $item_infos->{deleted_on} = dt_from_string;
342 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
346 =head3 effective_itemtype
348 Returns the itemtype for the item based on whether item level itemtypes are set or not.
352 sub effective_itemtype {
355 return $self->_result()->effective_itemtype();
365 my $hb_rs = $self->_result->homebranch;
367 return Koha::Library->_new_from_dbic( $hb_rs );
370 =head3 holding_branch
377 my $hb_rs = $self->_result->holdingbranch;
379 return Koha::Library->_new_from_dbic( $hb_rs );
384 my $biblio = $item->biblio;
386 Return the bibliographic record of this item
392 my $biblio_rs = $self->_result->biblio;
393 return Koha::Biblio->_new_from_dbic( $biblio_rs );
398 my $biblioitem = $item->biblioitem;
400 Return the biblioitem record of this item
406 my $biblioitem_rs = $self->_result->biblioitem;
407 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
412 my $checkout = $item->checkout;
414 Return the checkout for this item
420 my $checkout_rs = $self->_result->issue;
421 return unless $checkout_rs;
422 return Koha::Checkout->_new_from_dbic( $checkout_rs );
427 my $item_group = $item->item_group;
429 Return the item group for this item
436 my $item_group_item = $self->_result->item_group_item;
437 return unless $item_group_item;
439 my $item_group_rs = $item_group_item->item_group;
440 return unless $item_group_rs;
442 my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs );
448 my $return_claims = $item->return_claims;
450 Return any return_claims associated with this item
455 my ( $self, $params, $attrs ) = @_;
456 my $claims_rs = $self->_result->return_claims->search($params, $attrs);
457 return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs );
462 my $return_claim = $item->return_claim;
464 Returns the most recent unresolved return_claims associated with this item
471 $self->_result->return_claims->search( { resolution => undef },
472 { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single;
473 return unless $claims_rs;
474 return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs);
479 my $holds = $item->holds();
480 my $holds = $item->holds($params);
481 my $holds = $item->holds({ found => 'W'});
483 Return holds attached to an item, optionally accept a hashref of params to pass to search
488 my ( $self,$params ) = @_;
489 my $holds_rs = $self->_result->reserves->search($params);
490 return Koha::Holds->_new_from_dbic( $holds_rs );
493 =head3 request_transfer
495 my $transfer = $item->request_transfer(
499 [ ignore_limits => 0, enqueue => 1, replace => 1 ]
503 Add a transfer request for this item to the given branch for the given reason.
505 An exception will be thrown if the BranchTransferLimits would prevent the requested
506 transfer, unless 'ignore_limits' is passed to override the limits.
508 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
509 The caller should catch such cases and retry the transfer request as appropriate passing
510 an appropriate override.
513 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
514 * replace - Used to replace the existing transfer request with your own.
518 sub request_transfer {
519 my ( $self, $params ) = @_;
521 # check for mandatory params
522 my @mandatory = ( 'to', 'reason' );
523 for my $param (@mandatory) {
524 unless ( defined( $params->{$param} ) ) {
525 Koha::Exceptions::MissingParameter->throw(
526 error => "The $param parameter is mandatory" );
530 Koha::Exceptions::Item::Transfer::Limit->throw()
531 unless ( $params->{ignore_limits}
532 || $self->can_be_transferred( { to => $params->{to} } ) );
534 my $request = $self->get_transfer;
535 Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
536 if ( $request && !$params->{enqueue} && !$params->{replace} );
538 $request->cancel( { reason => $params->{reason}, force => 1 } )
539 if ( defined($request) && $params->{replace} );
541 my $transfer = Koha::Item::Transfer->new(
543 itemnumber => $self->itemnumber,
544 daterequested => dt_from_string,
545 frombranch => $self->holdingbranch,
546 tobranch => $params->{to}->branchcode,
547 reason => $params->{reason},
548 comments => $params->{comment}
557 my $transfer = $item->get_transfer;
559 Return the active transfer request or undef
561 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
562 whereby the most recently sent, but not received, transfer will be returned
563 if it exists, otherwise the oldest unsatisfied transfer will be returned.
565 This allows for transfers to queue, which is the case for stock rotation and
566 rotating collections where a manual transfer may need to take precedence but
567 we still expect the item to end up at a final location eventually.
574 return $self->get_transfers->search( {}, { rows => 1 } )->next;
579 my $transfer = $item->get_transfers;
581 Return the list of outstanding transfers (i.e requested but not yet cancelled
584 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
585 whereby the most recently sent, but not received, transfer will be returned
586 first if it exists, otherwise requests are in oldest to newest request order.
588 This allows for transfers to queue, which is the case for stock rotation and
589 rotating collections where a manual transfer may need to take precedence but
590 we still expect the item to end up at a final location eventually.
597 my $transfer_rs = $self->_result->branchtransfers;
599 return Koha::Item::Transfers
600 ->_new_from_dbic($transfer_rs)
602 ->search( {}, { order_by => [ { -desc => 'datesent' }, { -asc => 'daterequested' } ], } );
605 =head3 last_returned_by
607 Gets and sets the last borrower to return an item.
609 Accepts and returns Koha::Patron objects
611 $item->last_returned_by( $borrowernumber );
613 $last_returned_by = $item->last_returned_by();
617 sub last_returned_by {
618 my ( $self, $borrower ) = @_;
620 my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
623 return $items_last_returned_by_rs->update_or_create(
624 { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
627 unless ( $self->{_last_returned_by} ) {
628 my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
630 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
634 return $self->{_last_returned_by};
638 =head3 can_article_request
640 my $bool = $item->can_article_request( $borrower )
642 Returns true if item can be specifically requested
644 $borrower must be a Koha::Patron object
648 sub can_article_request {
649 my ( $self, $borrower ) = @_;
651 my $rule = $self->article_request_type($borrower);
653 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
657 =head3 hidden_in_opac
659 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
661 Returns true if item fields match the hidding criteria defined in $rules.
662 Returns false otherwise.
664 Takes HASHref that can have the following parameters:
666 $rules : { <field> => [ value_1, ... ], ... }
668 Note: $rules inherits its structure from the parsed YAML from reading
669 the I<OpacHiddenItems> system preference.
674 my ( $self, $params ) = @_;
676 my $rules = $params->{rules} // {};
679 if C4::Context->preference('hidelostitems') and
682 my $hidden_in_opac = 0;
684 foreach my $field ( keys %{$rules} ) {
686 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
692 return $hidden_in_opac;
695 =head3 can_be_transferred
697 $item->can_be_transferred({ to => $to_library, from => $from_library })
698 Checks if an item can be transferred to given library.
700 This feature is controlled by two system preferences:
701 UseBranchTransferLimits to enable / disable the feature
702 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
703 for setting the limitations
705 Takes HASHref that can have the following parameters:
706 MANDATORY PARAMETERS:
709 $from : Koha::Library # if not given, item holdingbranch
710 # will be used instead
712 Returns 1 if item can be transferred to $to_library, otherwise 0.
714 To find out whether at least one item of a Koha::Biblio can be transferred, please
715 see Koha::Biblio->can_be_transferred() instead of using this method for
716 multiple items of the same biblio.
720 sub can_be_transferred {
721 my ($self, $params) = @_;
723 my $to = $params->{to};
724 my $from = $params->{from};
726 $to = $to->branchcode;
727 $from = defined $from ? $from->branchcode : $self->holdingbranch;
729 return 1 if $from eq $to; # Transfer to current branch is allowed
730 return 1 unless C4::Context->preference('UseBranchTransferLimits');
732 my $limittype = C4::Context->preference('BranchTransferLimitsType');
733 return Koha::Item::Transfer::Limits->search({
736 $limittype => $limittype eq 'itemtype'
737 ? $self->effective_itemtype : $self->ccode
742 =head3 pickup_locations
744 $pickup_locations = $item->pickup_locations( {patron => $patron } )
746 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)
747 and if item can be transferred to each pickup location.
751 sub pickup_locations {
752 my ($self, $params) = @_;
754 my $patron = $params->{patron};
756 my $circ_control_branch =
757 C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
759 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
761 if(defined $patron) {
762 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
763 return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
766 my $pickup_libraries = Koha::Libraries->search();
767 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
768 $pickup_libraries = $self->home_branch->get_hold_libraries;
769 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
770 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
771 $pickup_libraries = $plib->get_hold_libraries;
772 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
773 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
774 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
775 $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
778 return $pickup_libraries->search(
783 order_by => ['branchname']
785 ) unless C4::Context->preference('UseBranchTransferLimits');
787 my $limittype = C4::Context->preference('BranchTransferLimitsType');
788 my ($ccode, $itype) = (undef, undef);
789 if( $limittype eq 'ccode' ){
790 $ccode = $self->ccode;
792 $itype = $self->itype;
794 my $limits = Koha::Item::Transfer::Limits->search(
796 fromBranch => $self->holdingbranch,
800 { columns => ['toBranch'] }
803 return $pickup_libraries->search(
805 pickup_location => 1,
807 '-not_in' => $limits->_resultset->as_query
811 order_by => ['branchname']
816 =head3 article_request_type
818 my $type = $item->article_request_type( $borrower )
820 returns 'yes', 'no', 'bib_only', or 'item_only'
822 $borrower must be a Koha::Patron object
826 sub article_request_type {
827 my ( $self, $borrower ) = @_;
829 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
831 $branch_control eq 'homebranch' ? $self->homebranch
832 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
834 my $borrowertype = $borrower->categorycode;
835 my $itemtype = $self->effective_itemtype();
836 my $rule = Koha::CirculationRules->get_effective_rule(
838 rule_name => 'article_requests',
839 categorycode => $borrowertype,
840 itemtype => $itemtype,
841 branchcode => $branchcode
845 return q{} unless $rule;
846 return $rule->rule_value || q{}
855 my $attributes = { order_by => 'priority' };
856 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
858 itemnumber => $self->itemnumber,
861 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
862 waitingdate => { '!=' => undef },
865 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
866 return Koha::Holds->_new_from_dbic($hold_rs);
869 =head3 stockrotationitem
871 my $sritem = Koha::Item->stockrotationitem;
873 Returns the stock rotation item associated with the current item.
877 sub stockrotationitem {
879 my $rs = $self->_result->stockrotationitem;
881 return Koha::StockRotationItem->_new_from_dbic( $rs );
886 my $item = $item->add_to_rota($rota_id);
888 Add this item to the rota identified by $ROTA_ID, which means associating it
889 with the first stage of that rota. Should this item already be associated
890 with a rota, then we will move it to the new rota.
895 my ( $self, $rota_id ) = @_;
896 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
900 =head3 has_pending_hold
902 my $is_pending_hold = $item->has_pending_hold();
904 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
908 sub has_pending_hold {
910 my $pending_hold = $self->_result->tmp_holdsqueues;
911 return $pending_hold->count ? 1: 0;
914 =head3 has_pending_recall {
916 my $has_pending_recall
918 Return if whether has pending recall of not.
922 sub has_pending_recall {
925 # FIXME Must be moved to $self->recalls
926 return Koha::Recalls->search(
928 item_id => $self->itemnumber,
936 my $field = $item->as_marc_field;
938 This method returns a MARC::Field object representing the Koha::Item object
939 with the current mappings configuration.
946 my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
948 my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
952 my $item_field = $tagslib->{$itemtag};
954 my $more_subfields = $self->additional_attributes->to_hashref;
955 foreach my $subfield (
957 $a->{display_order} <=> $b->{display_order}
958 || $a->{subfield} cmp $b->{subfield}
959 } grep { ref($_) && %$_ } values %$item_field
962 my $kohafield = $subfield->{kohafield};
963 my $tagsubfield = $subfield->{tagsubfield};
965 if ( defined $kohafield ) {
966 next if $kohafield !~ m{^items\.}; # That would be weird!
967 ( my $attribute = $kohafield ) =~ s|^items\.||;
968 $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
969 if defined $self->$attribute and $self->$attribute ne '';
971 $value = $more_subfields->{$tagsubfield}
974 next unless defined $value
977 if ( $subfield->{repeatable} ) {
978 my @values = split '\|', $value;
979 push @subfields, ( $tagsubfield => $_ ) for @values;
982 push @subfields, ( $tagsubfield => $value );
987 return unless @subfields;
989 return MARC::Field->new(
990 "$itemtag", ' ', ' ', @subfields
994 =head3 renewal_branchcode
996 Returns the branchcode to be recorded in statistics renewal of the item
1000 sub renewal_branchcode {
1002 my ($self, $params ) = @_;
1004 my $interface = C4::Context->interface;
1006 if ( $interface eq 'opac' ){
1007 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1008 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
1009 $branchcode = 'OPACRenew';
1011 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
1012 $branchcode = $self->homebranch;
1014 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1015 $branchcode = $self->checkout->patron->branchcode;
1017 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1018 $branchcode = $self->checkout->branchcode;
1024 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1025 ? C4::Context->userenv->{branch} : $params->{branch};
1032 Return the cover images associated with this item.
1039 my $cover_image_rs = $self->_result->cover_images;
1040 return unless $cover_image_rs;
1041 return Koha::CoverImages->_new_from_dbic($cover_image_rs);
1044 =head3 columns_to_str
1046 my $values = $items->columns_to_str;
1048 Return a hashref with the string representation of the different attribute of the item.
1050 This is meant to be used for display purpose only.
1054 sub columns_to_str {
1057 my $frameworkcode = $self->biblio->frameworkcode;
1058 my $tagslib = C4::Biblio::GetMarcStructure(1, $frameworkcode);
1059 my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1061 my $columns_info = $self->_result->result_source->columns_info;
1063 my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1065 for my $column ( keys %$columns_info ) {
1067 next if $column eq 'more_subfields_xml';
1069 my $value = $self->$column;
1070 # 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
1072 if ( not defined $value or $value eq "" ) {
1073 $values->{$column} = $value;
1078 exists $mss->{"items.$column"}
1079 ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1082 $values->{$column} =
1084 ? $subfield->{authorised_value}
1085 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1086 $subfield->{tagsubfield}, $value, '', $tagslib )
1092 $self->more_subfields_xml
1093 ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1098 my ( $field ) = $marc_more->fields;
1099 for my $sf ( $field->subfields ) {
1100 my $subfield_code = $sf->[0];
1101 my $value = $sf->[1];
1102 my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1103 next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1105 $subfield->{authorised_value}
1106 ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1107 $subfield->{tagsubfield}, $value, '', $tagslib )
1110 push @{$more_values->{$subfield_code}}, $value;
1113 while ( my ( $k, $v ) = each %$more_values ) {
1114 $values->{$k} = join ' | ', @$v;
1121 =head3 additional_attributes
1123 my $attributes = $item->additional_attributes;
1124 $attributes->{k} = 'new k';
1125 $item->update({ more_subfields => $attributes->to_marcxml });
1127 Returns a Koha::Item::Attributes object that represents the non-mapped
1128 attributes for this item.
1132 sub additional_attributes {
1135 return Koha::Item::Attributes->new_from_marcxml(
1136 $self->more_subfields_xml,
1140 =head3 _set_found_trigger
1142 $self->_set_found_trigger
1144 Finds the most recent lost item charge for this item and refunds the patron
1145 appropriately, taking into account any payments or writeoffs already applied
1148 Internal function, not exported, called only by Koha::Item->store.
1152 sub _set_found_trigger {
1153 my ( $self, $pre_mod_item ) = @_;
1155 # Reverse any lost item charges if necessary.
1156 my $no_refund_after_days =
1157 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1158 if ($no_refund_after_days) {
1159 my $today = dt_from_string();
1160 my $lost_age_in_days =
1161 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1164 return $self unless $lost_age_in_days < $no_refund_after_days;
1167 my $lostreturn_policy = Koha::CirculationRules->get_lostreturn_policy(
1170 return_branch => C4::Context->userenv
1171 ? C4::Context->userenv->{'branch'}
1176 if ( $lostreturn_policy ) {
1178 # refund charge made for lost book
1179 my $lost_charge = Koha::Account::Lines->search(
1181 itemnumber => $self->itemnumber,
1182 debit_type_code => 'LOST',
1183 status => [ undef, { '<>' => 'FOUND' } ]
1186 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1191 if ( $lost_charge ) {
1193 my $patron = $lost_charge->patron;
1196 my $account = $patron->account;
1197 my $total_to_refund = 0;
1200 if ( $lost_charge->amount > $lost_charge->amountoutstanding ) {
1202 # some amount has been cancelled. collect the offsets that are not writeoffs
1203 # this works because the only way to subtract from this kind of a debt is
1204 # using the UI buttons 'Pay' and 'Write off'
1205 my $credit_offsets = $lost_charge->debit_offsets(
1207 'credit_id' => { '!=' => undef },
1208 'credit.credit_type_code' => { '!=' => 'Writeoff' }
1210 { join => 'credit' }
1213 $total_to_refund = ( $credit_offsets->count > 0 )
1214 ? $credit_offsets->total * -1 # credits are negative on the DB
1218 my $credit_total = $lost_charge->amountoutstanding + $total_to_refund;
1221 if ( $credit_total > 0 ) {
1223 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1224 $credit = $account->add_credit(
1226 amount => $credit_total,
1227 description => 'Item found ' . $self->itemnumber,
1228 type => 'LOST_FOUND',
1229 interface => C4::Context->interface,
1230 library_id => $branchcode,
1231 item_id => $self->itemnumber,
1232 issue_id => $lost_charge->issue_id
1236 $credit->apply( { debits => [$lost_charge] } );
1240 message => 'lost_refunded',
1241 payload => { credit_id => $credit->id }
1246 # Update the account status
1247 $lost_charge->status('FOUND');
1248 $lost_charge->store();
1250 # Reconcile balances if required
1251 if ( C4::Context->preference('AccountAutoReconcile') ) {
1252 $account->reconcile_balance;
1257 # restore fine for lost book
1258 if ( $lostreturn_policy eq 'restore' ) {
1259 my $lost_overdue = Koha::Account::Lines->search(
1261 itemnumber => $self->itemnumber,
1262 debit_type_code => 'OVERDUE',
1266 order_by => { '-desc' => 'date' },
1271 if ( $lost_overdue ) {
1273 my $patron = $lost_overdue->patron;
1275 my $account = $patron->account;
1277 # Update status of fine
1278 $lost_overdue->status('FOUND')->store();
1280 # Find related forgive credit
1281 my $refund = $lost_overdue->credits(
1283 credit_type_code => 'FORGIVEN',
1284 itemnumber => $self->itemnumber,
1285 status => [ { '!=' => 'VOID' }, undef ]
1287 { order_by => { '-desc' => 'date' }, rows => 1 }
1291 # Revert the forgive credit
1292 $refund->void({ interface => 'trigger' });
1296 message => 'lost_restored',
1297 payload => { refund_id => $refund->id }
1302 # Reconcile balances if required
1303 if ( C4::Context->preference('AccountAutoReconcile') ) {
1304 $account->reconcile_balance;
1308 } elsif ( $lostreturn_policy eq 'charge' ) {
1312 message => 'lost_charge',
1321 =head3 public_read_list
1323 This method returns the list of publicly readable database fields for both API and UI output purposes
1327 sub public_read_list {
1329 'itemnumber', 'biblionumber', 'homebranch',
1330 'holdingbranch', 'location', 'collectioncode',
1331 'itemcallnumber', 'copynumber', 'enumchron',
1332 'barcode', 'dateaccessioned', 'itemnotes',
1333 'onloan', 'uri', 'itype',
1334 'notforloan', 'damaged', 'itemlost',
1335 'withdrawn', 'restricted'
1341 Overloaded to_api method to ensure item-level itypes is adhered to.
1346 my ($self, $params) = @_;
1348 my $response = $self->SUPER::to_api($params);
1351 $overrides->{effective_item_type_id} = $self->effective_itemtype;
1352 $overrides->{effective_not_for_loan_status} = $self->notforloan ? $self->notforloan : $self->itemtype->notforloan;
1354 return { %$response, %$overrides };
1357 =head3 to_api_mapping
1359 This method returns the mapping for representing a Koha::Item object
1364 sub to_api_mapping {
1366 itemnumber => 'item_id',
1367 biblionumber => 'biblio_id',
1368 biblioitemnumber => undef,
1369 barcode => 'external_id',
1370 dateaccessioned => 'acquisition_date',
1371 booksellerid => 'acquisition_source',
1372 homebranch => 'home_library_id',
1373 price => 'purchase_price',
1374 replacementprice => 'replacement_price',
1375 replacementpricedate => 'replacement_price_date',
1376 datelastborrowed => 'last_checkout_date',
1377 datelastseen => 'last_seen_date',
1379 notforloan => 'not_for_loan_status',
1380 damaged => 'damaged_status',
1381 damaged_on => 'damaged_date',
1382 itemlost => 'lost_status',
1383 itemlost_on => 'lost_date',
1384 withdrawn => 'withdrawn',
1385 withdrawn_on => 'withdrawn_date',
1386 itemcallnumber => 'callnumber',
1387 coded_location_qualifier => 'coded_location_qualifier',
1388 issues => 'checkouts_count',
1389 renewals => 'renewals_count',
1390 reserves => 'holds_count',
1391 restricted => 'restricted_status',
1392 itemnotes => 'public_notes',
1393 itemnotes_nonpublic => 'internal_notes',
1394 holdingbranch => 'holding_library_id',
1395 timestamp => 'timestamp',
1396 location => 'location',
1397 permanent_location => 'permanent_location',
1398 onloan => 'checked_out_date',
1399 cn_source => 'call_number_source',
1400 cn_sort => 'call_number_sort',
1401 ccode => 'collection_code',
1402 materials => 'materials_notes',
1404 itype => 'item_type_id',
1405 more_subfields_xml => 'extended_subfields',
1406 enumchron => 'serial_issue_number',
1407 copynumber => 'copy_number',
1408 stocknumber => 'inventory_number',
1409 new_status => 'new_status',
1410 deleted_on => undef,
1416 my $itemtype = $item->itemtype;
1418 Returns Koha object for effective itemtype
1425 return Koha::ItemTypes->find( $self->effective_itemtype );
1430 my $orders = $item->orders();
1432 Returns a Koha::Acquisition::Orders object
1439 my $orders = $self->_result->item_orders;
1440 return Koha::Acquisition::Orders->_new_from_dbic($orders);
1443 =head3 tracked_links
1445 my $tracked_links = $item->tracked_links();
1447 Returns a Koha::TrackedLinks object
1454 my $tracked_links = $self->_result->linktrackers;
1455 return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1458 =head3 move_to_biblio
1460 $item->move_to_biblio($to_biblio[, $params]);
1462 Move the item to another biblio and update any references in other tables.
1464 The final optional parameter, C<$params>, is expected to contain the
1465 'skip_record_index' key, which is relayed down to Koha::Item->store.
1466 There it prevents calling index_records, which takes most of the
1467 time in batch adds/deletes. The caller must take care of calling
1468 index_records separately.
1471 skip_record_index => 1|0
1473 Returns undef if the move failed or the biblionumber of the destination record otherwise
1477 sub move_to_biblio {
1478 my ( $self, $to_biblio, $params ) = @_;
1482 return if $self->biblionumber == $to_biblio->biblionumber;
1484 my $from_biblionumber = $self->biblionumber;
1485 my $to_biblionumber = $to_biblio->biblionumber;
1487 # Own biblionumber and biblioitemnumber
1489 biblionumber => $to_biblionumber,
1490 biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1491 })->store({ skip_record_index => $params->{skip_record_index} });
1493 unless ($params->{skip_record_index}) {
1494 my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1495 $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1498 # Acquisition orders
1499 $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1502 $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1504 # hold_fill_target (there's no Koha object available yet)
1505 my $hold_fill_target = $self->_result->hold_fill_target;
1506 if ($hold_fill_target) {
1507 $hold_fill_target->update({ biblionumber => $to_biblionumber });
1510 # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1511 # and can't even fake one since the significant columns are nullable.
1512 my $storage = $self->_result->result_source->storage;
1515 my ($storage, $dbh, @cols) = @_;
1517 $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1522 $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1524 return $to_biblionumber;
1529 my $bundle_items = $item->bundle_items;
1531 Returns the items associated with this bundle
1538 if ( !$self->{_bundle_items_cached} ) {
1539 my $bundle_items = Koha::Items->search(
1540 { 'item_bundles_item.host' => $self->itemnumber },
1541 { join => 'item_bundles_item' } );
1542 $self->{_bundle_items} = $bundle_items;
1543 $self->{_bundle_items_cached} = 1;
1546 return $self->{_bundle_items};
1551 my $is_bundle = $item->is_bundle;
1553 Returns whether the item is a bundle or not
1559 return $self->bundle_items->count ? 1 : 0;
1564 my $bundle = $item->bundle_host;
1566 Returns the bundle item this item is attached to
1573 my $bundle_items_rs = $self->_result->item_bundles_item;
1574 return unless $bundle_items_rs;
1575 return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1580 my $in_bundle = $item->in_bundle;
1582 Returns whether this item is currently in a bundle
1588 return $self->bundle_host ? 1 : 0;
1591 =head3 add_to_bundle
1593 my $link = $item->add_to_bundle($bundle_item);
1595 Adds the bundle_item passed to this item
1600 my ( $self, $bundle_item ) = @_;
1602 my $schema = Koha::Database->new->schema;
1604 my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1609 $self->_result->add_to_item_bundles_hosts(
1610 { item => $bundle_item->itemnumber } );
1612 $bundle_item->notforloan($BundleNotLoanValue)->store();
1618 # 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
1619 if ( ref($_) eq 'DBIx::Class::Exception' ) {
1621 if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1623 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1624 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1625 Koha::Exceptions::Object::FKConstraint->throw(
1626 error => 'Broken FK constraint',
1627 broken_fk => $+{column}
1632 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
1634 Koha::Exceptions::Object::DuplicateID->throw(
1635 error => 'Duplicate ID',
1636 duplicate_id => $+{key}
1639 elsif ( $_->{msg} =~
1640 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
1642 { # The optional \W in the regex might be a quote or backtick
1643 my $type = $+{type};
1644 my $value = $+{value};
1645 my $property = $+{property};
1646 $property =~ s/['`]//g;
1647 Koha::Exceptions::Object::BadValue->throw(
1650 property => $property =~ /(\w+\.\w+)$/
1653 , # results in table.column without quotes or backtics
1657 # Catch-all for foreign key breakages. It will help find other use cases
1666 =head3 remove_from_bundle
1668 Remove this item from any bundle it may have been attached to.
1672 sub remove_from_bundle {
1675 my $bundle_item_rs = $self->_result->item_bundles_item;
1676 if ( $bundle_item_rs ) {
1677 $bundle_item_rs->delete;
1678 $self->notforloan(0)->store();
1684 =head2 Internal methods
1686 =head3 _after_item_action_hooks
1688 Helper method that takes care of calling all plugin hooks
1692 sub _after_item_action_hooks {
1693 my ( $self, $params ) = @_;
1695 my $action = $params->{action};
1697 Koha::Plugins->call(
1698 'after_item_action',
1702 item_id => $self->itemnumber,
1709 my $recall = $item->recall;
1711 Return the relevant recall for this item
1717 my @recalls = Koha::Recalls->search(
1719 biblio_id => $self->biblionumber,
1722 { order_by => { -asc => 'created_date' } }
1724 foreach my $recall (@recalls) {
1725 if ( $recall->item_level and $recall->item_id == $self->itemnumber ){
1729 # no item-level recall to return, so return earliest biblio-level
1730 # FIXME: eventually this will be based on priority
1734 =head3 can_be_recalled
1736 if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
1738 Does item-level checks and returns if items can be recalled by this borrower
1742 sub can_be_recalled {
1743 my ( $self, $params ) = @_;
1745 return 0 if !( C4::Context->preference('UseRecalls') );
1747 # check if this item is not for loan, withdrawn or lost
1748 return 0 if ( $self->notforloan != 0 );
1749 return 0 if ( $self->itemlost != 0 );
1750 return 0 if ( $self->withdrawn != 0 );
1752 # check if this item is not checked out - if not checked out, can't be recalled
1753 return 0 if ( !defined( $self->checkout ) );
1755 my $patron = $params->{patron};
1757 my $branchcode = C4::Context->userenv->{'branch'};
1759 $branchcode = C4::Circulation::_GetCircControlBranch( $self->unblessed, $patron->unblessed );
1762 # Check the circulation rule for each relevant itemtype for this item
1763 my $rule = Koha::CirculationRules->get_effective_rules({
1764 branchcode => $branchcode,
1765 categorycode => $patron ? $patron->categorycode : undef,
1766 itemtype => $self->effective_itemtype,
1769 'recalls_per_record',
1774 # check recalls allowed has been set and is not zero
1775 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
1778 # check borrower has not reached open recalls allowed limit
1779 return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
1781 # check borrower has not reach open recalls allowed per record limit
1782 return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
1784 # check if this patron has already recalled this item
1785 return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
1787 # check if this patron has already checked out this item
1788 return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
1790 # check if this patron has already reserved this item
1791 return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
1794 # check item availability
1795 # items are unavailable for recall if they are lost, withdrawn or notforloan
1796 my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
1798 # if there are no available items at all, no recall can be placed
1799 return 0 if ( scalar @items == 0 );
1801 my $checked_out_count = 0;
1803 if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
1806 # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
1807 return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
1809 # can't recall if no items have been checked out
1810 return 0 if ( $checked_out_count == 0 );
1816 =head3 can_be_waiting_recall
1818 if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
1820 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
1821 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
1825 sub can_be_waiting_recall {
1828 return 0 if !( C4::Context->preference('UseRecalls') );
1830 # check if this item is not for loan, withdrawn or lost
1831 return 0 if ( $self->notforloan != 0 );
1832 return 0 if ( $self->itemlost != 0 );
1833 return 0 if ( $self->withdrawn != 0 );
1835 my $branchcode = $self->holdingbranch;
1836 if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
1837 $branchcode = C4::Context->userenv->{'branch'};
1839 $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
1842 # Check the circulation rule for each relevant itemtype for this item
1843 my $rule = Koha::CirculationRules->get_effective_rules({
1844 branchcode => $branchcode,
1845 categorycode => undef,
1846 itemtype => $self->effective_itemtype,
1852 # check recalls allowed has been set and is not zero
1853 return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
1859 =head3 check_recalls
1861 my $recall = $item->check_recalls;
1863 Get the most relevant recall for this item.
1870 my @recalls = Koha::Recalls->search(
1871 { biblio_id => $self->biblionumber,
1872 item_id => [ $self->itemnumber, undef ]
1874 { order_by => { -asc => 'created_date' } }
1875 )->filter_by_current->as_list;
1878 # iterate through relevant recalls to find the best one.
1879 # if we come across a waiting recall, use this one.
1880 # 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.
1881 foreach my $r ( @recalls ) {
1882 if ( $r->waiting ) {
1887 unless ( defined $recall ) {
1888 $recall = $recalls[0];
1894 =head3 is_notforloan
1896 my $is_notforloan = $item->is_notforloan;
1898 Determine whether or not this item is "notforloan" based on
1899 the item's notforloan status or its item type
1905 my $is_notforloan = 0;
1907 if ( $self->notforloan ){
1911 my $itemtype = $self->itemtype;
1913 if ( $itemtype->notforloan ){
1919 return $is_notforloan;
1932 Kyle M Hall <kyle@bywatersolutions.com>