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>.
23 use List::MoreUtils qw(any);
28 use Koha::DateUtils qw( dt_from_string );
33 use C4::Biblio qw( ModZebra ); # FIXME This is terrible, we should move the indexation code outside of C4::Biblio
34 use C4::ClassSource; # FIXME We would like to avoid that
35 use C4::Log qw( logaction );
38 use Koha::CirculationRules;
39 use Koha::Item::Transfer::Limits;
40 use Koha::Item::Transfers;
45 use Koha::StockRotationItem;
46 use Koha::StockRotationRotas;
48 use base qw(Koha::Object);
52 Koha::Item - Koha Item object class
64 $params can take an optional 'skip_modzebra_update' parameter.
65 If set, the reindexation process will not happen (ModZebra not called)
67 NOTE: This is a temporary fix to answer a performance issue when lot of items
68 are added (or modified) at the same time.
69 The correct way to fix this is to make the ES reindexation process async.
70 You should not turn it on if you do not understand what it is doing exactly.
76 my $params = @_ ? shift : {};
78 my $log_action = $params->{log_action} // 1;
80 # We do not want to oblige callers to pass this value
81 # Dev conveniences vs performance?
82 unless ( $self->biblioitemnumber ) {
83 $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
86 # See related changes from C4::Items::AddItem
87 unless ( $self->itype ) {
88 $self->itype($self->biblio->biblioitem->itemtype);
91 my $today = dt_from_string;
92 unless ( $self->in_storage ) { #AddItem
93 unless ( $self->permanent_location ) {
94 $self->permanent_location($self->location);
96 unless ( $self->replacementpricedate ) {
97 $self->replacementpricedate($today);
99 unless ( $self->datelastseen ) {
100 $self->datelastseen($today);
103 unless ( $self->dateaccessioned ) {
104 $self->dateaccessioned($today);
107 if ( $self->itemcallnumber
108 or $self->cn_source )
110 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
111 $self->cn_sort($cn_sort);
114 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
115 unless $params->{skip_modzebra_update};
117 logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
118 if $log_action && C4::Context->preference("CataloguingLog");
120 $self->_after_item_action_hooks({ action => 'create' });
124 { # Update *_on fields if needed
125 # Why not for AddItem as well?
126 my @fields = qw( itemlost withdrawn damaged );
128 # Only retrieve the item if we need to set an "on" date field
129 if ( $self->itemlost || $self->withdrawn || $self->damaged ) {
130 my $pre_mod_item = $self->get_from_storage;
131 for my $field (@fields) {
133 and not $pre_mod_item->$field )
135 my $field_on = "${field}_on";
137 DateTime::Format::MySQL->format_datetime( dt_from_string() )
143 # If the field is defined but empty, we are removing and,
144 # and thus need to clear out the 'on' field as well
145 for my $field (@fields) {
146 if ( defined( $self->$field ) && !$self->$field ) {
147 my $field_on = "${field}_on";
148 $self->$field_on(undef);
153 my %updated_columns = $self->_result->get_dirty_columns;
154 return $self->SUPER::store unless %updated_columns;
156 if ( exists $updated_columns{itemcallnumber}
157 or exists $updated_columns{cn_source} )
159 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
160 $self->cn_sort($cn_sort);
164 if ( exists $updated_columns{location}
165 and $self->location ne 'CART'
166 and $self->location ne 'PROC'
167 and not exists $updated_columns{permanent_location} )
169 $self->permanent_location( $self->location );
172 # If item was lost, it has now been found, reverse any list item charges if necessary.
173 if ( exists $updated_columns{itemlost}
174 and $self->itemlost != $updated_columns{itemlost}
175 and $updated_columns{itemlost} >= 1 ) {
176 $self->_set_found_trigger;
180 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
181 unless $params->{skip_modzebra_update};
183 $self->_after_item_action_hooks({ action => 'modify' });
185 logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper($self->unblessed) )
186 if $log_action && C4::Context->preference("CataloguingLog");
189 unless ( $self->dateaccessioned ) {
190 $self->dateaccessioned($today);
193 return $self->SUPER::store;
202 my $params = @_ ? shift : {};
204 # FIXME check the item has no current issues
205 # i.e. raise the appropriate exception
207 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
208 unless $params->{skip_modzebra_update};
210 $self->_after_item_action_hooks({ action => 'delete' });
212 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
213 if C4::Context->preference("CataloguingLog");
215 return $self->SUPER::delete;
224 my $params = @_ ? shift : {};
226 my $safe_to_delete = $self->safe_to_delete;
227 return $safe_to_delete unless $safe_to_delete eq '1';
229 $self->move_to_deleted;
231 return $self->delete($params);
234 =head3 safe_to_delete
236 returns 1 if the item is safe to delete,
238 "book_on_loan" if the item is checked out,
240 "not_same_branch" if the item is blocked by independent branches,
242 "book_reserved" if the there are holds aganst the item, or
244 "linked_analytics" if the item has linked analytic records.
246 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
253 return "book_on_loan" if $self->checkout;
255 return "not_same_branch"
256 if defined C4::Context->userenv
257 and !C4::Context->IsSuperLibrarian()
258 and C4::Context->preference("IndependentBranches")
259 and ( C4::Context->userenv->{branch} ne $self->homebranch );
261 # check it doesn't have a waiting reserve
262 return "book_reserved"
263 if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
265 return "linked_analytics"
266 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
268 return "last_item_for_hold"
269 if $self->biblio->items->count == 1
270 && $self->biblio->holds->search(
279 =head3 move_to_deleted
281 my $is_moved = $item->move_to_deleted;
283 Move an item to the deleteditems table.
284 This can be done before deleting an item, to make sure the data are not completely deleted.
288 sub move_to_deleted {
290 my $item_infos = $self->unblessed;
291 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
292 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
296 =head3 effective_itemtype
298 Returns the itemtype for the item based on whether item level itemtypes are set or not.
302 sub effective_itemtype {
305 return $self->_result()->effective_itemtype();
315 $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
317 return $self->{_home_branch};
320 =head3 holding_branch
327 $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
329 return $self->{_holding_branch};
334 my $biblio = $item->biblio;
336 Return the bibliographic record of this item
342 my $biblio_rs = $self->_result->biblio;
343 return Koha::Biblio->_new_from_dbic( $biblio_rs );
348 my $biblioitem = $item->biblioitem;
350 Return the biblioitem record of this item
356 my $biblioitem_rs = $self->_result->biblioitem;
357 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
362 my $checkout = $item->checkout;
364 Return the checkout for this item
370 my $checkout_rs = $self->_result->issue;
371 return unless $checkout_rs;
372 return Koha::Checkout->_new_from_dbic( $checkout_rs );
377 my $holds = $item->holds();
378 my $holds = $item->holds($params);
379 my $holds = $item->holds({ found => 'W'});
381 Return holds attached to an item, optionally accept a hashref of params to pass to search
386 my ( $self,$params ) = @_;
387 my $holds_rs = $self->_result->reserves->search($params);
388 return Koha::Holds->_new_from_dbic( $holds_rs );
393 my $transfer = $item->get_transfer;
395 Return the transfer if the item is in transit or undef
401 my $transfer_rs = $self->_result->branchtransfers->search({ datearrived => undef })->first;
402 return unless $transfer_rs;
403 return Koha::Item::Transfer->_new_from_dbic( $transfer_rs );
406 =head3 last_returned_by
408 Gets and sets the last borrower to return an item.
410 Accepts and returns Koha::Patron objects
412 $item->last_returned_by( $borrowernumber );
414 $last_returned_by = $item->last_returned_by();
418 sub last_returned_by {
419 my ( $self, $borrower ) = @_;
421 my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
424 return $items_last_returned_by_rs->update_or_create(
425 { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
428 unless ( $self->{_last_returned_by} ) {
429 my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
431 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
435 return $self->{_last_returned_by};
439 =head3 can_article_request
441 my $bool = $item->can_article_request( $borrower )
443 Returns true if item can be specifically requested
445 $borrower must be a Koha::Patron object
449 sub can_article_request {
450 my ( $self, $borrower ) = @_;
452 my $rule = $self->article_request_type($borrower);
454 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
458 =head3 hidden_in_opac
460 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
462 Returns true if item fields match the hidding criteria defined in $rules.
463 Returns false otherwise.
465 Takes HASHref that can have the following parameters:
467 $rules : { <field> => [ value_1, ... ], ... }
469 Note: $rules inherits its structure from the parsed YAML from reading
470 the I<OpacHiddenItems> system preference.
475 my ( $self, $params ) = @_;
477 my $rules = $params->{rules} // {};
480 if C4::Context->preference('hidelostitems') and
483 my $hidden_in_opac = 0;
485 foreach my $field ( keys %{$rules} ) {
487 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
493 return $hidden_in_opac;
496 =head3 can_be_transferred
498 $item->can_be_transferred({ to => $to_library, from => $from_library })
499 Checks if an item can be transferred to given library.
501 This feature is controlled by two system preferences:
502 UseBranchTransferLimits to enable / disable the feature
503 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
504 for setting the limitations
506 Takes HASHref that can have the following parameters:
507 MANDATORY PARAMETERS:
510 $from : Koha::Library # if not given, item holdingbranch
511 # will be used instead
513 Returns 1 if item can be transferred to $to_library, otherwise 0.
515 To find out whether at least one item of a Koha::Biblio can be transferred, please
516 see Koha::Biblio->can_be_transferred() instead of using this method for
517 multiple items of the same biblio.
521 sub can_be_transferred {
522 my ($self, $params) = @_;
524 my $to = $params->{to};
525 my $from = $params->{from};
527 $to = $to->branchcode;
528 $from = defined $from ? $from->branchcode : $self->holdingbranch;
530 return 1 if $from eq $to; # Transfer to current branch is allowed
531 return 1 unless C4::Context->preference('UseBranchTransferLimits');
533 my $limittype = C4::Context->preference('BranchTransferLimitsType');
534 return Koha::Item::Transfer::Limits->search({
537 $limittype => $limittype eq 'itemtype'
538 ? $self->effective_itemtype : $self->ccode
542 =head3 pickup_locations
544 $pickup_locations = $item->pickup_locations( {patron => $patron } )
546 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)
547 and if item can be transferred to each pickup location.
551 sub pickup_locations {
552 my ($self, $params) = @_;
554 my $patron = $params->{patron};
556 my $circ_control_branch =
557 C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
559 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
562 if(defined $patron) {
563 return \@libs if $branchitemrule->{holdallowed} == 3 && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
564 return \@libs if $branchitemrule->{holdallowed} == 1 && $self->home_branch->branchcode ne $patron->branchcode;
567 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
568 @libs = $self->home_branch->get_hold_libraries;
569 push @libs, $self->home_branch unless scalar(@libs) > 0;
570 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
571 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
572 @libs = $plib->get_hold_libraries;
573 push @libs, $self->home_branch unless scalar(@libs) > 0;
574 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
575 push @libs, $self->home_branch;
576 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
577 push @libs, $self->holding_branch;
579 @libs = Koha::Libraries->search({
582 order_by => ['branchname']
586 my @pickup_locations;
587 foreach my $library (@libs) {
588 if ($library->pickup_location && $self->can_be_transferred({ to => $library })) {
589 push @pickup_locations, $library;
593 return \@pickup_locations;
596 =head3 article_request_type
598 my $type = $item->article_request_type( $borrower )
600 returns 'yes', 'no', 'bib_only', or 'item_only'
602 $borrower must be a Koha::Patron object
606 sub article_request_type {
607 my ( $self, $borrower ) = @_;
609 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
611 $branch_control eq 'homebranch' ? $self->homebranch
612 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
614 my $borrowertype = $borrower->categorycode;
615 my $itemtype = $self->effective_itemtype();
616 my $rule = Koha::CirculationRules->get_effective_rule(
618 rule_name => 'article_requests',
619 categorycode => $borrowertype,
620 itemtype => $itemtype,
621 branchcode => $branchcode
625 return q{} unless $rule;
626 return $rule->rule_value || q{}
635 my $attributes = { order_by => 'priority' };
636 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
638 itemnumber => $self->itemnumber,
641 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
642 waitingdate => { '!=' => undef },
645 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
646 return Koha::Holds->_new_from_dbic($hold_rs);
649 =head3 stockrotationitem
651 my $sritem = Koha::Item->stockrotationitem;
653 Returns the stock rotation item associated with the current item.
657 sub stockrotationitem {
659 my $rs = $self->_result->stockrotationitem;
661 return Koha::StockRotationItem->_new_from_dbic( $rs );
666 my $item = $item->add_to_rota($rota_id);
668 Add this item to the rota identified by $ROTA_ID, which means associating it
669 with the first stage of that rota. Should this item already be associated
670 with a rota, then we will move it to the new rota.
675 my ( $self, $rota_id ) = @_;
676 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
680 =head3 has_pending_hold
682 my $is_pending_hold = $item->has_pending_hold();
684 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
688 sub has_pending_hold {
690 my $pending_hold = $self->_result->tmp_holdsqueues;
691 return $pending_hold->count ? 1: 0;
696 my $mss = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
697 my $field = $item->as_marc_field({ [ mss => $mss ] });
699 This method returns a MARC::Field object representing the Koha::Item object
700 with the current mappings configuration.
705 my ( $self, $params ) = @_;
707 my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
708 my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
712 my @columns = $self->_result->result_source->columns;
714 foreach my $item_field ( @columns ) {
715 my $mapping = $mss->{ "items.$item_field"}[0];
716 my $tagfield = $mapping->{tagfield};
717 my $tagsubfield = $mapping->{tagsubfield};
718 next if !$tagfield; # TODO: Should we raise an exception instead?
719 # Feels like safe fallback is better
721 push @subfields, $tagsubfield => $self->$item_field
722 if defined $self->$item_field and $item_field ne '';
725 my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
726 push( @subfields, @{$unlinked_item_subfields} )
727 if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
731 $field = MARC::Field->new(
732 "$item_tag", ' ', ' ', @subfields
738 =head3 renewal_branchcode
740 Returns the branchcode to be recorded in statistics renewal of the item
744 sub renewal_branchcode {
746 my ($self, $params ) = @_;
748 my $interface = C4::Context->interface;
750 if ( $interface eq 'opac' ){
751 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
752 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
753 $branchcode = 'OPACRenew';
755 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
756 $branchcode = $self->homebranch;
758 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
759 $branchcode = $self->checkout->patron->branchcode;
761 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
762 $branchcode = $self->checkout->branchcode;
768 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
769 ? C4::Context->userenv->{branch} : $params->{branch};
774 =head3 _set_found_trigger
776 $self->_set_found_trigger
778 Finds the most recent lost item charge for this item and refunds the patron
779 appropriatly, taking into account any payments or writeoffs already applied
782 Internal function, not exported, called only by Koha::Item->store.
786 sub _set_found_trigger {
787 my ( $self, $params ) = @_;
789 ## If item was lost, it has now been found, reverse any list item charges if necessary.
791 my $no_refund_after_days =
792 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
793 if ($no_refund_after_days) {
794 my $today = dt_from_string();
795 my $lost_age_in_days =
796 dt_from_string( $self->itemlost_on )->delta_days($today)
799 return $self unless $lost_age_in_days < $no_refund_after_days;
803 unless Koha::CirculationRules->get_lostreturn_policy(
805 current_branch => C4::Context->userenv->{branch},
810 # check for charge made for lost book
811 my $accountlines = Koha::Account::Lines->search(
813 itemnumber => $self->itemnumber,
814 debit_type_code => 'LOST',
815 status => [ undef, { '<>' => 'FOUND' } ]
818 order_by => { -desc => [ 'date', 'accountlines_id' ] }
822 return $self unless $accountlines->count > 0;
824 my $accountline = $accountlines->next;
825 my $total_to_refund = 0;
827 return $self unless $accountline->borrowernumber;
829 my $patron = Koha::Patrons->find( $accountline->borrowernumber );
831 unless $patron; # Patron has been deleted, nobody to credit the return to
832 # FIXME Should not we notify this somehwere
834 my $account = $patron->account;
837 if ( $accountline->amount > $accountline->amountoutstanding ) {
839 # some amount has been cancelled. collect the offsets that are not writeoffs
840 # this works because the only way to subtract from this kind of a debt is
841 # using the UI buttons 'Pay' and 'Write off'
842 my $credits_offsets = Koha::Account::Offsets->search(
844 debit_id => $accountline->id,
845 credit_id => { '!=' => undef }, # it is not the debit itself
846 type => { '!=' => 'Writeoff' },
847 amount => { '<' => 0 } # credits are negative on the DB
851 $total_to_refund = ( $credits_offsets->count > 0 )
852 ? $credits_offsets->total * -1 # credits are negative on the DB
856 my $credit_total = $accountline->amountoutstanding + $total_to_refund;
859 if ( $credit_total > 0 ) {
861 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
862 $credit = $account->add_credit(
864 amount => $credit_total,
865 description => 'Item found ' . $item_id,
866 type => 'LOST_FOUND',
867 interface => C4::Context->interface,
868 library_id => $branchcode,
869 item_id => $itemnumber
873 $credit->apply( { debits => [$accountline] } );
876 # Update the account status
877 $accountline->discard_changes->status('FOUND')
878 ; # FIXME JD Why discard_changes? $accountline has not been modified since last fetch
881 if ( defined $account and C4::Context->preference('AccountAutoReconcile') ) {
882 $account->reconcile_balance;
888 =head3 to_api_mapping
890 This method returns the mapping for representing a Koha::Item object
897 itemnumber => 'item_id',
898 biblionumber => 'biblio_id',
899 biblioitemnumber => undef,
900 barcode => 'external_id',
901 dateaccessioned => 'acquisition_date',
902 booksellerid => 'acquisition_source',
903 homebranch => 'home_library_id',
904 price => 'purchase_price',
905 replacementprice => 'replacement_price',
906 replacementpricedate => 'replacement_price_date',
907 datelastborrowed => 'last_checkout_date',
908 datelastseen => 'last_seen_date',
910 notforloan => 'not_for_loan_status',
911 damaged => 'damaged_status',
912 damaged_on => 'damaged_date',
913 itemlost => 'lost_status',
914 itemlost_on => 'lost_date',
915 withdrawn => 'withdrawn',
916 withdrawn_on => 'withdrawn_date',
917 itemcallnumber => 'callnumber',
918 coded_location_qualifier => 'coded_location_qualifier',
919 issues => 'checkouts_count',
920 renewals => 'renewals_count',
921 reserves => 'holds_count',
922 restricted => 'restricted_status',
923 itemnotes => 'public_notes',
924 itemnotes_nonpublic => 'internal_notes',
925 holdingbranch => 'holding_library_id',
927 timestamp => 'timestamp',
928 location => 'location',
929 permanent_location => 'permanent_location',
930 onloan => 'checked_out_date',
931 cn_source => 'call_number_source',
932 cn_sort => 'call_number_sort',
933 ccode => 'collection_code',
934 materials => 'materials_notes',
936 itype => 'item_type',
937 more_subfields_xml => 'extended_subfields',
938 enumchron => 'serial_issue_number',
939 copynumber => 'copy_number',
940 stocknumber => 'inventory_number',
941 new_status => 'new_status'
947 my $itemtype = $item->itemtype;
949 Returns Koha object for effective itemtype
955 return Koha::ItemTypes->find( $self->effective_itemtype );
958 =head2 Internal methods
960 =head3 _after_item_action_hooks
962 Helper method that takes care of calling all plugin hooks
966 sub _after_item_action_hooks {
967 my ( $self, $params ) = @_;
969 my $action = $params->{action};
976 item_id => $self->itemnumber,
991 Kyle M Hall <kyle@bywatersolutions.com>