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 my %updated_columns = $self->_result->get_dirty_columns;
125 return $self->SUPER::store unless %updated_columns;
127 # Retreive the item for comparison if we need to
128 my $pre_mod_item = $self->get_from_storage
129 if ( exists $updated_columns{itemlost}
130 or exists $updated_columns{withdrawn}
131 or exists $updated_columns{damaged} );
133 # Update *_on fields if needed
134 # FIXME: Why not for AddItem as well?
135 my @fields = qw( itemlost withdrawn damaged );
136 for my $field (@fields) {
138 # If the field is defined but empty or 0, we are
139 # removing/unsetting and thus need to clear out
141 if ( exists $updated_columns{$field}
142 && defined( $self->$field )
145 my $field_on = "${field}_on";
146 $self->$field_on(undef);
148 # If the field has changed otherwise, we much update
150 elsif (exists $updated_columns{$field}
151 && $updated_columns{$field}
152 && !$pre_mod_item->$field )
154 my $field_on = "${field}_on";
156 DateTime::Format::MySQL->format_datetime(
163 if ( exists $updated_columns{itemcallnumber}
164 or exists $updated_columns{cn_source} )
166 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
167 $self->cn_sort($cn_sort);
171 if ( exists $updated_columns{location}
172 and $self->location ne 'CART'
173 and $self->location ne 'PROC'
174 and not exists $updated_columns{permanent_location} )
176 $self->permanent_location( $self->location );
179 # If item was lost and has now been found,
180 # reverse any list item charges if necessary.
181 if ( exists $updated_columns{itemlost}
182 and $updated_columns{itemlost} <= 0
183 and $pre_mod_item->itemlost > 0 )
185 $self->_set_found_trigger($pre_mod_item);
189 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
190 unless $params->{skip_modzebra_update};
192 $self->_after_item_action_hooks({ action => 'modify' });
194 logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper($self->unblessed) )
195 if $log_action && C4::Context->preference("CataloguingLog");
198 unless ( $self->dateaccessioned ) {
199 $self->dateaccessioned($today);
202 return $self->SUPER::store;
211 my $params = @_ ? shift : {};
213 # FIXME check the item has no current issues
214 # i.e. raise the appropriate exception
216 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
217 unless $params->{skip_modzebra_update};
219 $self->_after_item_action_hooks({ action => 'delete' });
221 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
222 if C4::Context->preference("CataloguingLog");
224 return $self->SUPER::delete;
233 my $params = @_ ? shift : {};
235 my $safe_to_delete = $self->safe_to_delete;
236 return $safe_to_delete unless $safe_to_delete eq '1';
238 $self->move_to_deleted;
240 return $self->delete($params);
243 =head3 safe_to_delete
245 returns 1 if the item is safe to delete,
247 "book_on_loan" if the item is checked out,
249 "not_same_branch" if the item is blocked by independent branches,
251 "book_reserved" if the there are holds aganst the item, or
253 "linked_analytics" if the item has linked analytic records.
255 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
262 return "book_on_loan" if $self->checkout;
264 return "not_same_branch"
265 if defined C4::Context->userenv
266 and !C4::Context->IsSuperLibrarian()
267 and C4::Context->preference("IndependentBranches")
268 and ( C4::Context->userenv->{branch} ne $self->homebranch );
270 # check it doesn't have a waiting reserve
271 return "book_reserved"
272 if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
274 return "linked_analytics"
275 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
277 return "last_item_for_hold"
278 if $self->biblio->items->count == 1
279 && $self->biblio->holds->search(
288 =head3 move_to_deleted
290 my $is_moved = $item->move_to_deleted;
292 Move an item to the deleteditems table.
293 This can be done before deleting an item, to make sure the data are not completely deleted.
297 sub move_to_deleted {
299 my $item_infos = $self->unblessed;
300 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
301 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
305 =head3 effective_itemtype
307 Returns the itemtype for the item based on whether item level itemtypes are set or not.
311 sub effective_itemtype {
314 return $self->_result()->effective_itemtype();
324 $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
326 return $self->{_home_branch};
329 =head3 holding_branch
336 $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
338 return $self->{_holding_branch};
343 my $biblio = $item->biblio;
345 Return the bibliographic record of this item
351 my $biblio_rs = $self->_result->biblio;
352 return Koha::Biblio->_new_from_dbic( $biblio_rs );
357 my $biblioitem = $item->biblioitem;
359 Return the biblioitem record of this item
365 my $biblioitem_rs = $self->_result->biblioitem;
366 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
371 my $checkout = $item->checkout;
373 Return the checkout for this item
379 my $checkout_rs = $self->_result->issue;
380 return unless $checkout_rs;
381 return Koha::Checkout->_new_from_dbic( $checkout_rs );
386 my $holds = $item->holds();
387 my $holds = $item->holds($params);
388 my $holds = $item->holds({ found => 'W'});
390 Return holds attached to an item, optionally accept a hashref of params to pass to search
395 my ( $self,$params ) = @_;
396 my $holds_rs = $self->_result->reserves->search($params);
397 return Koha::Holds->_new_from_dbic( $holds_rs );
402 my $transfer = $item->get_transfer;
404 Return the transfer if the item is in transit or undef
410 my $transfer_rs = $self->_result->branchtransfers->search({ datearrived => undef })->first;
411 return unless $transfer_rs;
412 return Koha::Item::Transfer->_new_from_dbic( $transfer_rs );
415 =head3 last_returned_by
417 Gets and sets the last borrower to return an item.
419 Accepts and returns Koha::Patron objects
421 $item->last_returned_by( $borrowernumber );
423 $last_returned_by = $item->last_returned_by();
427 sub last_returned_by {
428 my ( $self, $borrower ) = @_;
430 my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
433 return $items_last_returned_by_rs->update_or_create(
434 { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
437 unless ( $self->{_last_returned_by} ) {
438 my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
440 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
444 return $self->{_last_returned_by};
448 =head3 can_article_request
450 my $bool = $item->can_article_request( $borrower )
452 Returns true if item can be specifically requested
454 $borrower must be a Koha::Patron object
458 sub can_article_request {
459 my ( $self, $borrower ) = @_;
461 my $rule = $self->article_request_type($borrower);
463 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
467 =head3 hidden_in_opac
469 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
471 Returns true if item fields match the hidding criteria defined in $rules.
472 Returns false otherwise.
474 Takes HASHref that can have the following parameters:
476 $rules : { <field> => [ value_1, ... ], ... }
478 Note: $rules inherits its structure from the parsed YAML from reading
479 the I<OpacHiddenItems> system preference.
484 my ( $self, $params ) = @_;
486 my $rules = $params->{rules} // {};
489 if C4::Context->preference('hidelostitems') and
492 my $hidden_in_opac = 0;
494 foreach my $field ( keys %{$rules} ) {
496 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
502 return $hidden_in_opac;
505 =head3 can_be_transferred
507 $item->can_be_transferred({ to => $to_library, from => $from_library })
508 Checks if an item can be transferred to given library.
510 This feature is controlled by two system preferences:
511 UseBranchTransferLimits to enable / disable the feature
512 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
513 for setting the limitations
515 Takes HASHref that can have the following parameters:
516 MANDATORY PARAMETERS:
519 $from : Koha::Library # if not given, item holdingbranch
520 # will be used instead
522 Returns 1 if item can be transferred to $to_library, otherwise 0.
524 To find out whether at least one item of a Koha::Biblio can be transferred, please
525 see Koha::Biblio->can_be_transferred() instead of using this method for
526 multiple items of the same biblio.
530 sub can_be_transferred {
531 my ($self, $params) = @_;
533 my $to = $params->{to};
534 my $from = $params->{from};
536 $to = $to->branchcode;
537 $from = defined $from ? $from->branchcode : $self->holdingbranch;
539 return 1 if $from eq $to; # Transfer to current branch is allowed
540 return 1 unless C4::Context->preference('UseBranchTransferLimits');
542 my $limittype = C4::Context->preference('BranchTransferLimitsType');
543 return Koha::Item::Transfer::Limits->search({
546 $limittype => $limittype eq 'itemtype'
547 ? $self->effective_itemtype : $self->ccode
551 =head3 pickup_locations
553 $pickup_locations = $item->pickup_locations( {patron => $patron } )
555 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)
556 and if item can be transferred to each pickup location.
560 sub pickup_locations {
561 my ($self, $params) = @_;
563 my $patron = $params->{patron};
565 my $circ_control_branch =
566 C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
568 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
571 if(defined $patron) {
572 return \@libs if $branchitemrule->{holdallowed} == 3 && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
573 return \@libs if $branchitemrule->{holdallowed} == 1 && $self->home_branch->branchcode ne $patron->branchcode;
576 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
577 @libs = $self->home_branch->get_hold_libraries;
578 push @libs, $self->home_branch unless scalar(@libs) > 0;
579 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
580 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
581 @libs = $plib->get_hold_libraries;
582 push @libs, $self->home_branch unless scalar(@libs) > 0;
583 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
584 push @libs, $self->home_branch;
585 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
586 push @libs, $self->holding_branch;
588 @libs = Koha::Libraries->search({
591 order_by => ['branchname']
595 my @pickup_locations;
596 foreach my $library (@libs) {
597 if ($library->pickup_location && $self->can_be_transferred({ to => $library })) {
598 push @pickup_locations, $library;
602 return \@pickup_locations;
605 =head3 article_request_type
607 my $type = $item->article_request_type( $borrower )
609 returns 'yes', 'no', 'bib_only', or 'item_only'
611 $borrower must be a Koha::Patron object
615 sub article_request_type {
616 my ( $self, $borrower ) = @_;
618 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
620 $branch_control eq 'homebranch' ? $self->homebranch
621 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
623 my $borrowertype = $borrower->categorycode;
624 my $itemtype = $self->effective_itemtype();
625 my $rule = Koha::CirculationRules->get_effective_rule(
627 rule_name => 'article_requests',
628 categorycode => $borrowertype,
629 itemtype => $itemtype,
630 branchcode => $branchcode
634 return q{} unless $rule;
635 return $rule->rule_value || q{}
644 my $attributes = { order_by => 'priority' };
645 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
647 itemnumber => $self->itemnumber,
650 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
651 waitingdate => { '!=' => undef },
654 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
655 return Koha::Holds->_new_from_dbic($hold_rs);
658 =head3 stockrotationitem
660 my $sritem = Koha::Item->stockrotationitem;
662 Returns the stock rotation item associated with the current item.
666 sub stockrotationitem {
668 my $rs = $self->_result->stockrotationitem;
670 return Koha::StockRotationItem->_new_from_dbic( $rs );
675 my $item = $item->add_to_rota($rota_id);
677 Add this item to the rota identified by $ROTA_ID, which means associating it
678 with the first stage of that rota. Should this item already be associated
679 with a rota, then we will move it to the new rota.
684 my ( $self, $rota_id ) = @_;
685 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
689 =head3 has_pending_hold
691 my $is_pending_hold = $item->has_pending_hold();
693 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
697 sub has_pending_hold {
699 my $pending_hold = $self->_result->tmp_holdsqueues;
700 return $pending_hold->count ? 1: 0;
705 my $mss = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
706 my $field = $item->as_marc_field({ [ mss => $mss ] });
708 This method returns a MARC::Field object representing the Koha::Item object
709 with the current mappings configuration.
714 my ( $self, $params ) = @_;
716 my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
717 my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
721 my @columns = $self->_result->result_source->columns;
723 foreach my $item_field ( @columns ) {
724 my $mapping = $mss->{ "items.$item_field"}[0];
725 my $tagfield = $mapping->{tagfield};
726 my $tagsubfield = $mapping->{tagsubfield};
727 next if !$tagfield; # TODO: Should we raise an exception instead?
728 # Feels like safe fallback is better
730 push @subfields, $tagsubfield => $self->$item_field
731 if defined $self->$item_field and $item_field ne '';
734 my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
735 push( @subfields, @{$unlinked_item_subfields} )
736 if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
740 $field = MARC::Field->new(
741 "$item_tag", ' ', ' ', @subfields
747 =head3 renewal_branchcode
749 Returns the branchcode to be recorded in statistics renewal of the item
753 sub renewal_branchcode {
755 my ($self, $params ) = @_;
757 my $interface = C4::Context->interface;
759 if ( $interface eq 'opac' ){
760 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
761 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
762 $branchcode = 'OPACRenew';
764 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
765 $branchcode = $self->homebranch;
767 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
768 $branchcode = $self->checkout->patron->branchcode;
770 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
771 $branchcode = $self->checkout->branchcode;
777 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
778 ? C4::Context->userenv->{branch} : $params->{branch};
783 =head3 _set_found_trigger
785 $self->_set_found_trigger
787 Finds the most recent lost item charge for this item and refunds the patron
788 appropriatly, taking into account any payments or writeoffs already applied
791 Internal function, not exported, called only by Koha::Item->store.
795 sub _set_found_trigger {
796 my ( $self, $pre_mod_item ) = @_;
798 ## If item was lost, it has now been found, reverse any list item charges if necessary.
799 my $no_refund_after_days =
800 C4::Context->preference('NoRefundOnLostReturnedItemsAge');
801 if ($no_refund_after_days) {
802 my $today = dt_from_string();
803 my $lost_age_in_days =
804 dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
807 return $self unless $lost_age_in_days < $no_refund_after_days;
811 unless Koha::CirculationRules->get_lostreturn_policy(
813 current_branch => C4::Context->userenv->{branch},
818 # check for charge made for lost book
819 my $accountlines = Koha::Account::Lines->search(
821 itemnumber => $self->itemnumber,
822 debit_type_code => 'LOST',
823 status => [ undef, { '<>' => 'FOUND' } ]
826 order_by => { -desc => [ 'date', 'accountlines_id' ] }
830 return $self unless $accountlines->count > 0;
832 my $accountline = $accountlines->next;
833 my $total_to_refund = 0;
835 return $self unless $accountline->borrowernumber;
837 my $patron = Koha::Patrons->find( $accountline->borrowernumber );
839 unless $patron; # Patron has been deleted, nobody to credit the return to
840 # FIXME Should not we notify this somehwere
842 my $account = $patron->account;
845 if ( $accountline->amount > $accountline->amountoutstanding ) {
847 # some amount has been cancelled. collect the offsets that are not writeoffs
848 # this works because the only way to subtract from this kind of a debt is
849 # using the UI buttons 'Pay' and 'Write off'
850 my $credits_offsets = Koha::Account::Offsets->search(
852 debit_id => $accountline->id,
853 credit_id => { '!=' => undef }, # it is not the debit itself
854 type => { '!=' => 'Writeoff' },
855 amount => { '<' => 0 } # credits are negative on the DB
859 $total_to_refund = ( $credits_offsets->count > 0 )
860 ? $credits_offsets->total * -1 # credits are negative on the DB
864 my $credit_total = $accountline->amountoutstanding + $total_to_refund;
867 if ( $credit_total > 0 ) {
869 C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
870 $credit = $account->add_credit(
872 amount => $credit_total,
873 description => 'Item found ' . $self->itemnumber,
874 type => 'LOST_FOUND',
875 interface => C4::Context->interface,
876 library_id => $branchcode,
877 item_id => $self->itemnumber,
878 issue_id => $accountline->issue_id
882 $credit->apply( { debits => [$accountline] } );
883 $self->{_refunded} = 1;
886 # Update the account status
887 $accountline->status('FOUND');
888 $accountline->store();
890 if ( defined $account and C4::Context->preference('AccountAutoReconcile') ) {
891 $account->reconcile_balance;
897 =head3 to_api_mapping
899 This method returns the mapping for representing a Koha::Item object
906 itemnumber => 'item_id',
907 biblionumber => 'biblio_id',
908 biblioitemnumber => undef,
909 barcode => 'external_id',
910 dateaccessioned => 'acquisition_date',
911 booksellerid => 'acquisition_source',
912 homebranch => 'home_library_id',
913 price => 'purchase_price',
914 replacementprice => 'replacement_price',
915 replacementpricedate => 'replacement_price_date',
916 datelastborrowed => 'last_checkout_date',
917 datelastseen => 'last_seen_date',
919 notforloan => 'not_for_loan_status',
920 damaged => 'damaged_status',
921 damaged_on => 'damaged_date',
922 itemlost => 'lost_status',
923 itemlost_on => 'lost_date',
924 withdrawn => 'withdrawn',
925 withdrawn_on => 'withdrawn_date',
926 itemcallnumber => 'callnumber',
927 coded_location_qualifier => 'coded_location_qualifier',
928 issues => 'checkouts_count',
929 renewals => 'renewals_count',
930 reserves => 'holds_count',
931 restricted => 'restricted_status',
932 itemnotes => 'public_notes',
933 itemnotes_nonpublic => 'internal_notes',
934 holdingbranch => 'holding_library_id',
936 timestamp => 'timestamp',
937 location => 'location',
938 permanent_location => 'permanent_location',
939 onloan => 'checked_out_date',
940 cn_source => 'call_number_source',
941 cn_sort => 'call_number_sort',
942 ccode => 'collection_code',
943 materials => 'materials_notes',
945 itype => 'item_type',
946 more_subfields_xml => 'extended_subfields',
947 enumchron => 'serial_issue_number',
948 copynumber => 'copy_number',
949 stocknumber => 'inventory_number',
950 new_status => 'new_status'
956 my $itemtype = $item->itemtype;
958 Returns Koha object for effective itemtype
964 return Koha::ItemTypes->find( $self->effective_itemtype );
967 =head2 Internal methods
969 =head3 _after_item_action_hooks
971 Helper method that takes care of calling all plugin hooks
975 sub _after_item_action_hooks {
976 my ( $self, $params ) = @_;
978 my $action = $params->{action};
985 item_id => $self->itemnumber,
1000 Kyle M Hall <kyle@bywatersolutions.com>