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 $self->itemlost >= 1
176 and $updated_columns{itemlost} <= 0
178 $self->_set_found_trigger;
182 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
183 unless $params->{skip_modzebra_update};
185 $self->_after_item_action_hooks({ action => 'modify' });
187 logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper($self->unblessed) )
188 if $log_action && C4::Context->preference("CataloguingLog");
191 unless ( $self->dateaccessioned ) {
192 $self->dateaccessioned($today);
195 return $self->SUPER::store;
204 my $params = @_ ? shift : {};
206 # FIXME check the item has no current issues
207 # i.e. raise the appropriate exception
209 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
210 unless $params->{skip_modzebra_update};
212 $self->_after_item_action_hooks({ action => 'delete' });
214 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
215 if C4::Context->preference("CataloguingLog");
217 return $self->SUPER::delete;
226 my $params = @_ ? shift : {};
228 my $safe_to_delete = $self->safe_to_delete;
229 return $safe_to_delete unless $safe_to_delete eq '1';
231 $self->move_to_deleted;
233 return $self->delete($params);
236 =head3 safe_to_delete
238 returns 1 if the item is safe to delete,
240 "book_on_loan" if the item is checked out,
242 "not_same_branch" if the item is blocked by independent branches,
244 "book_reserved" if the there are holds aganst the item, or
246 "linked_analytics" if the item has linked analytic records.
248 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
255 return "book_on_loan" if $self->checkout;
257 return "not_same_branch"
258 if defined C4::Context->userenv
259 and !C4::Context->IsSuperLibrarian()
260 and C4::Context->preference("IndependentBranches")
261 and ( C4::Context->userenv->{branch} ne $self->homebranch );
263 # check it doesn't have a waiting reserve
264 return "book_reserved"
265 if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
267 return "linked_analytics"
268 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
270 return "last_item_for_hold"
271 if $self->biblio->items->count == 1
272 && $self->biblio->holds->search(
281 =head3 move_to_deleted
283 my $is_moved = $item->move_to_deleted;
285 Move an item to the deleteditems table.
286 This can be done before deleting an item, to make sure the data are not completely deleted.
290 sub move_to_deleted {
292 my $item_infos = $self->unblessed;
293 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
294 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
298 =head3 effective_itemtype
300 Returns the itemtype for the item based on whether item level itemtypes are set or not.
304 sub effective_itemtype {
307 return $self->_result()->effective_itemtype();
317 $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
319 return $self->{_home_branch};
322 =head3 holding_branch
329 $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
331 return $self->{_holding_branch};
336 my $biblio = $item->biblio;
338 Return the bibliographic record of this item
344 my $biblio_rs = $self->_result->biblio;
345 return Koha::Biblio->_new_from_dbic( $biblio_rs );
350 my $biblioitem = $item->biblioitem;
352 Return the biblioitem record of this item
358 my $biblioitem_rs = $self->_result->biblioitem;
359 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
364 my $checkout = $item->checkout;
366 Return the checkout for this item
372 my $checkout_rs = $self->_result->issue;
373 return unless $checkout_rs;
374 return Koha::Checkout->_new_from_dbic( $checkout_rs );
379 my $holds = $item->holds();
380 my $holds = $item->holds($params);
381 my $holds = $item->holds({ found => 'W'});
383 Return holds attached to an item, optionally accept a hashref of params to pass to search
388 my ( $self,$params ) = @_;
389 my $holds_rs = $self->_result->reserves->search($params);
390 return Koha::Holds->_new_from_dbic( $holds_rs );
395 my $transfer = $item->get_transfer;
397 Return the transfer if the item is in transit or undef
403 my $transfer_rs = $self->_result->branchtransfers->search({ datearrived => undef })->first;
404 return unless $transfer_rs;
405 return Koha::Item::Transfer->_new_from_dbic( $transfer_rs );
408 =head3 last_returned_by
410 Gets and sets the last borrower to return an item.
412 Accepts and returns Koha::Patron objects
414 $item->last_returned_by( $borrowernumber );
416 $last_returned_by = $item->last_returned_by();
420 sub last_returned_by {
421 my ( $self, $borrower ) = @_;
423 my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
426 return $items_last_returned_by_rs->update_or_create(
427 { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
430 unless ( $self->{_last_returned_by} ) {
431 my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
433 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
437 return $self->{_last_returned_by};
441 =head3 can_article_request
443 my $bool = $item->can_article_request( $borrower )
445 Returns true if item can be specifically requested
447 $borrower must be a Koha::Patron object
451 sub can_article_request {
452 my ( $self, $borrower ) = @_;
454 my $rule = $self->article_request_type($borrower);
456 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
460 =head3 hidden_in_opac
462 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
464 Returns true if item fields match the hidding criteria defined in $rules.
465 Returns false otherwise.
467 Takes HASHref that can have the following parameters:
469 $rules : { <field> => [ value_1, ... ], ... }
471 Note: $rules inherits its structure from the parsed YAML from reading
472 the I<OpacHiddenItems> system preference.
477 my ( $self, $params ) = @_;
479 my $rules = $params->{rules} // {};
482 if C4::Context->preference('hidelostitems') and
485 my $hidden_in_opac = 0;
487 foreach my $field ( keys %{$rules} ) {
489 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
495 return $hidden_in_opac;
498 =head3 can_be_transferred
500 $item->can_be_transferred({ to => $to_library, from => $from_library })
501 Checks if an item can be transferred to given library.
503 This feature is controlled by two system preferences:
504 UseBranchTransferLimits to enable / disable the feature
505 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
506 for setting the limitations
508 Takes HASHref that can have the following parameters:
509 MANDATORY PARAMETERS:
512 $from : Koha::Library # if not given, item holdingbranch
513 # will be used instead
515 Returns 1 if item can be transferred to $to_library, otherwise 0.
517 To find out whether at least one item of a Koha::Biblio can be transferred, please
518 see Koha::Biblio->can_be_transferred() instead of using this method for
519 multiple items of the same biblio.
523 sub can_be_transferred {
524 my ($self, $params) = @_;
526 my $to = $params->{to};
527 my $from = $params->{from};
529 $to = $to->branchcode;
530 $from = defined $from ? $from->branchcode : $self->holdingbranch;
532 return 1 if $from eq $to; # Transfer to current branch is allowed
533 return 1 unless C4::Context->preference('UseBranchTransferLimits');
535 my $limittype = C4::Context->preference('BranchTransferLimitsType');
536 return Koha::Item::Transfer::Limits->search({
539 $limittype => $limittype eq 'itemtype'
540 ? $self->effective_itemtype : $self->ccode
544 =head3 pickup_locations
546 $pickup_locations = $item->pickup_locations( {patron => $patron } )
548 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)
549 and if item can be transferred to each pickup location.
553 sub pickup_locations {
554 my ($self, $params) = @_;
556 my $patron = $params->{patron};
558 my $circ_control_branch =
559 C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
561 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
564 if(defined $patron) {
565 return \@libs if $branchitemrule->{holdallowed} == 3 && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
566 return \@libs if $branchitemrule->{holdallowed} == 1 && $self->home_branch->branchcode ne $patron->branchcode;
569 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
570 @libs = $self->home_branch->get_hold_libraries;
571 push @libs, $self->home_branch unless scalar(@libs) > 0;
572 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
573 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
574 @libs = $plib->get_hold_libraries;
575 push @libs, $self->home_branch unless scalar(@libs) > 0;
576 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
577 push @libs, $self->home_branch;
578 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
579 push @libs, $self->holding_branch;
581 @libs = Koha::Libraries->search({
584 order_by => ['branchname']
588 my @pickup_locations;
589 foreach my $library (@libs) {
590 if ($library->pickup_location && $self->can_be_transferred({ to => $library })) {
591 push @pickup_locations, $library;
595 return \@pickup_locations;
598 =head3 article_request_type
600 my $type = $item->article_request_type( $borrower )
602 returns 'yes', 'no', 'bib_only', or 'item_only'
604 $borrower must be a Koha::Patron object
608 sub article_request_type {
609 my ( $self, $borrower ) = @_;
611 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
613 $branch_control eq 'homebranch' ? $self->homebranch
614 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
616 my $borrowertype = $borrower->categorycode;
617 my $itemtype = $self->effective_itemtype();
618 my $rule = Koha::CirculationRules->get_effective_rule(
620 rule_name => 'article_requests',
621 categorycode => $borrowertype,
622 itemtype => $itemtype,
623 branchcode => $branchcode
627 return q{} unless $rule;
628 return $rule->rule_value || q{}
637 my $attributes = { order_by => 'priority' };
638 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
640 itemnumber => $self->itemnumber,
643 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
644 waitingdate => { '!=' => undef },
647 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
648 return Koha::Holds->_new_from_dbic($hold_rs);
651 =head3 stockrotationitem
653 my $sritem = Koha::Item->stockrotationitem;
655 Returns the stock rotation item associated with the current item.
659 sub stockrotationitem {
661 my $rs = $self->_result->stockrotationitem;
663 return Koha::StockRotationItem->_new_from_dbic( $rs );
668 my $item = $item->add_to_rota($rota_id);
670 Add this item to the rota identified by $ROTA_ID, which means associating it
671 with the first stage of that rota. Should this item already be associated
672 with a rota, then we will move it to the new rota.
677 my ( $self, $rota_id ) = @_;
678 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
682 =head3 has_pending_hold
684 my $is_pending_hold = $item->has_pending_hold();
686 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
690 sub has_pending_hold {
692 my $pending_hold = $self->_result->tmp_holdsqueues;
693 return $pending_hold->count ? 1: 0;
698 my $mss = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
699 my $field = $item->as_marc_field({ [ mss => $mss ] });
701 This method returns a MARC::Field object representing the Koha::Item object
702 with the current mappings configuration.
707 my ( $self, $params ) = @_;
709 my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
710 my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
714 my @columns = $self->_result->result_source->columns;
716 foreach my $item_field ( @columns ) {
717 my $mapping = $mss->{ "items.$item_field"}[0];
718 my $tagfield = $mapping->{tagfield};
719 my $tagsubfield = $mapping->{tagsubfield};
720 next if !$tagfield; # TODO: Should we raise an exception instead?
721 # Feels like safe fallback is better
723 push @subfields, $tagsubfield => $self->$item_field
724 if defined $self->$item_field and $item_field ne '';
727 my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
728 push( @subfields, @{$unlinked_item_subfields} )
729 if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
733 $field = MARC::Field->new(
734 "$item_tag", ' ', ' ', @subfields
740 =head3 renewal_branchcode
742 Returns the branchcode to be recorded in statistics renewal of the item
746 sub renewal_branchcode {
748 my ($self, $params ) = @_;
750 my $interface = C4::Context->interface;
752 if ( $interface eq 'opac' ){
753 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
754 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
755 $branchcode = 'OPACRenew';
757 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
758 $branchcode = $self->homebranch;
760 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
761 $branchcode = $self->checkout->patron->branchcode;
763 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
764 $branchcode = $self->checkout->branchcode;
770 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
771 ? C4::Context->userenv->{branch} : $params->{branch};
776 =head3 _set_found_trigger
778 $self->_set_found_trigger
780 Finds the most recent lost item charge for this item and refunds the patron
781 appropriatly, taking into account any payments or writeoffs already applied
784 Internal function, not exported, called only by Koha::Item->store.
788 sub _set_found_trigger {
789 my ( $self, $params ) = @_;
791 ## If item was lost, it has now been found, reverse any list item charges if necessary.
793 my $no_refund_after_days =
794 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
795 if ($no_refund_after_days) {
796 my $today = dt_from_string();
797 my $lost_age_in_days =
798 dt_from_string( $self->itemlost_on )->delta_days($today)
801 return $self unless $lost_age_in_days < $no_refund_after_days;
805 unless Koha::CirculationRules->get_lostreturn_policy(
807 current_branch => C4::Context->userenv->{branch},
812 # check for charge made for lost book
813 my $accountlines = Koha::Account::Lines->search(
815 itemnumber => $self->itemnumber,
816 debit_type_code => 'LOST',
817 status => [ undef, { '<>' => 'FOUND' } ]
820 order_by => { -desc => [ 'date', 'accountlines_id' ] }
824 return $self unless $accountlines->count > 0;
826 my $accountline = $accountlines->next;
827 my $total_to_refund = 0;
829 return $self unless $accountline->borrowernumber;
831 my $patron = Koha::Patrons->find( $accountline->borrowernumber );
833 unless $patron; # Patron has been deleted, nobody to credit the return to
834 # FIXME Should not we notify this somehwere
836 my $account = $patron->account;
839 if ( $accountline->amount > $accountline->amountoutstanding ) {
841 # some amount has been cancelled. collect the offsets that are not writeoffs
842 # this works because the only way to subtract from this kind of a debt is
843 # using the UI buttons 'Pay' and 'Write off'
844 my $credits_offsets = Koha::Account::Offsets->search(
846 debit_id => $accountline->id,
847 credit_id => { '!=' => undef }, # it is not the debit itself
848 type => { '!=' => 'Writeoff' },
849 amount => { '<' => 0 } # credits are negative on the DB
853 $total_to_refund = ( $credits_offsets->count > 0 )
854 ? $credits_offsets->total * -1 # credits are negative on the DB
858 my $credit_total = $accountline->amountoutstanding + $total_to_refund;
861 if ( $credit_total > 0 ) {
863 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
864 $credit = $account->add_credit(
866 amount => $credit_total,
867 description => 'Item found ' . $item_id,
868 type => 'LOST_FOUND',
869 interface => C4::Context->interface,
870 library_id => $branchcode,
871 item_id => $itemnumber
875 $credit->apply( { debits => [$accountline] } );
876 $self->{_refunded} = 1;
879 # Update the account status
880 $accountline->discard_changes->status('FOUND')
881 ; # FIXME JD Why discard_changes? $accountline has not been modified since last fetch
884 if ( defined $account and C4::Context->preference('AccountAutoReconcile') ) {
885 $account->reconcile_balance;
891 =head3 to_api_mapping
893 This method returns the mapping for representing a Koha::Item object
900 itemnumber => 'item_id',
901 biblionumber => 'biblio_id',
902 biblioitemnumber => undef,
903 barcode => 'external_id',
904 dateaccessioned => 'acquisition_date',
905 booksellerid => 'acquisition_source',
906 homebranch => 'home_library_id',
907 price => 'purchase_price',
908 replacementprice => 'replacement_price',
909 replacementpricedate => 'replacement_price_date',
910 datelastborrowed => 'last_checkout_date',
911 datelastseen => 'last_seen_date',
913 notforloan => 'not_for_loan_status',
914 damaged => 'damaged_status',
915 damaged_on => 'damaged_date',
916 itemlost => 'lost_status',
917 itemlost_on => 'lost_date',
918 withdrawn => 'withdrawn',
919 withdrawn_on => 'withdrawn_date',
920 itemcallnumber => 'callnumber',
921 coded_location_qualifier => 'coded_location_qualifier',
922 issues => 'checkouts_count',
923 renewals => 'renewals_count',
924 reserves => 'holds_count',
925 restricted => 'restricted_status',
926 itemnotes => 'public_notes',
927 itemnotes_nonpublic => 'internal_notes',
928 holdingbranch => 'holding_library_id',
930 timestamp => 'timestamp',
931 location => 'location',
932 permanent_location => 'permanent_location',
933 onloan => 'checked_out_date',
934 cn_source => 'call_number_source',
935 cn_sort => 'call_number_sort',
936 ccode => 'collection_code',
937 materials => 'materials_notes',
939 itype => 'item_type',
940 more_subfields_xml => 'extended_subfields',
941 enumchron => 'serial_issue_number',
942 copynumber => 'copy_number',
943 stocknumber => 'inventory_number',
944 new_status => 'new_status'
950 my $itemtype = $item->itemtype;
952 Returns Koha object for effective itemtype
958 return Koha::ItemTypes->find( $self->effective_itemtype );
961 =head2 Internal methods
963 =head3 _after_item_action_hooks
965 Helper method that takes care of calling all plugin hooks
969 sub _after_item_action_hooks {
970 my ( $self, $params ) = @_;
972 my $action = $params->{action};
979 item_id => $self->itemnumber,
994 Kyle M Hall <kyle@bywatersolutions.com>