Bug 24027: Call ModZebra once after all items added/deleted in a batch
[koha.git] / Koha / Item.pm
bloba6ad7c6ac35f657b371dce98849fad4ab84e9ce3
1 package Koha::Item;
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>.
20 use Modern::Perl;
22 use Carp;
23 use List::MoreUtils qw(any);
24 use Data::Dumper;
25 use Try::Tiny;
27 use Koha::Database;
28 use Koha::DateUtils qw( dt_from_string );
30 use C4::Context;
31 use C4::Circulation;
32 use C4::Reserves;
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 );
37 use Koha::Checkouts;
38 use Koha::CirculationRules;
39 use Koha::Item::Transfer::Limits;
40 use Koha::Item::Transfers;
41 use Koha::Patrons;
42 use Koha::Plugins;
43 use Koha::Libraries;
44 use Koha::StockRotationItem;
45 use Koha::StockRotationRotas;
47 use base qw(Koha::Object);
49 =head1 NAME
51 Koha::Item - Koha Item object class
53 =head1 API
55 =head2 Class methods
57 =cut
59 =head3 store
61 =cut
63 sub store {
64 my $self = shift;
65 my $params = @_ ? shift : {};
67 my $log_action = $params->{log_action} // 1;
69 # We do not want to oblige callers to pass this value
70 # Dev conveniences vs performance?
71 unless ( $self->biblioitemnumber ) {
72 $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
75 # See related changes from C4::Items::AddItem
76 unless ( $self->itype ) {
77 $self->itype($self->biblio->biblioitem->itemtype);
80 my $today = dt_from_string;
81 unless ( $self->in_storage ) { #AddItem
82 unless ( $self->permanent_location ) {
83 $self->permanent_location($self->location);
85 unless ( $self->replacementpricedate ) {
86 $self->replacementpricedate($today);
88 unless ( $self->datelastseen ) {
89 $self->datelastseen($today);
92 unless ( $self->dateaccessioned ) {
93 $self->dateaccessioned($today);
96 if ( $self->itemcallnumber
97 or $self->cn_source )
99 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
100 $self->cn_sort($cn_sort);
103 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
104 unless $params->{skip_modzebra_update};
106 logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
107 if $log_action && C4::Context->preference("CataloguingLog");
109 $self->_after_item_action_hooks({ action => 'create' });
111 } else { # ModItem
113 { # Update *_on fields if needed
114 # Why not for AddItem as well?
115 my @fields = qw( itemlost withdrawn damaged );
117 # Only retrieve the item if we need to set an "on" date field
118 if ( $self->itemlost || $self->withdrawn || $self->damaged ) {
119 my $pre_mod_item = $self->get_from_storage;
120 for my $field (@fields) {
121 if ( $self->$field
122 and not $pre_mod_item->$field )
124 my $field_on = "${field}_on";
125 $self->$field_on(
126 DateTime::Format::MySQL->format_datetime( dt_from_string() )
132 # If the field is defined but empty, we are removing and,
133 # and thus need to clear out the 'on' field as well
134 for my $field (@fields) {
135 if ( defined( $self->$field ) && !$self->$field ) {
136 my $field_on = "${field}_on";
137 $self->$field_on(undef);
142 my %updated_columns = $self->_result->get_dirty_columns;
143 return $self->SUPER::store unless %updated_columns;
145 if ( exists $updated_columns{itemcallnumber}
146 or exists $updated_columns{cn_source} )
148 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
149 $self->cn_sort($cn_sort);
153 if ( exists $updated_columns{location}
154 and $self->location ne 'CART'
155 and $self->location ne 'PROC'
156 and not exists $updated_columns{permanent_location} )
158 $self->permanent_location( $self->location );
161 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
162 unless $params->{skip_modzebra_update};
164 $self->_after_item_action_hooks({ action => 'modify' });
166 logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper($self->unblessed) )
167 if $log_action && C4::Context->preference("CataloguingLog");
170 unless ( $self->dateaccessioned ) {
171 $self->dateaccessioned($today);
174 return $self->SUPER::store;
177 =head3 delete
179 =cut
181 sub delete {
182 my $self = shift;
183 my $params = @_ ? shift : {};
185 # FIXME check the item has no current issues
186 # i.e. raise the appropriate exception
188 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
189 unless $params->{skip_modzebra_update};
191 $self->_after_item_action_hooks({ action => 'delete' });
193 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
194 if C4::Context->preference("CataloguingLog");
196 return $self->SUPER::delete;
199 =head3 safe_delete
201 =cut
203 sub safe_delete {
204 my $self = shift;
205 my $params = @_ ? shift : {};
207 my $safe_to_delete = $self->safe_to_delete;
208 return $safe_to_delete unless $safe_to_delete eq '1';
210 $self->move_to_deleted;
212 return $self->delete($params);
215 =head3 safe_to_delete
217 returns 1 if the item is safe to delete,
219 "book_on_loan" if the item is checked out,
221 "not_same_branch" if the item is blocked by independent branches,
223 "book_reserved" if the there are holds aganst the item, or
225 "linked_analytics" if the item has linked analytic records.
227 =cut
229 sub safe_to_delete {
230 my ($self) = @_;
232 return "book_on_loan" if $self->checkout;
234 return "not_same_branch"
235 if defined C4::Context->userenv
236 and !C4::Context->IsSuperLibrarian()
237 and C4::Context->preference("IndependentBranches")
238 and ( C4::Context->userenv->{branch} ne $self->homebranch );
240 # check it doesn't have a waiting reserve
241 return "book_reserved"
242 if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
244 return "linked_analytics"
245 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
247 return 1;
250 =head3 move_to_deleted
252 my $is_moved = $item->move_to_deleted;
254 Move an item to the deleteditems table.
255 This can be done before deleting an item, to make sure the data are not completely deleted.
257 =cut
259 sub move_to_deleted {
260 my ($self) = @_;
261 my $item_infos = $self->unblessed;
262 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
263 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
267 =head3 effective_itemtype
269 Returns the itemtype for the item based on whether item level itemtypes are set or not.
271 =cut
273 sub effective_itemtype {
274 my ( $self ) = @_;
276 return $self->_result()->effective_itemtype();
279 =head3 home_branch
281 =cut
283 sub home_branch {
284 my ($self) = @_;
286 $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
288 return $self->{_home_branch};
291 =head3 holding_branch
293 =cut
295 sub holding_branch {
296 my ($self) = @_;
298 $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
300 return $self->{_holding_branch};
303 =head3 biblio
305 my $biblio = $item->biblio;
307 Return the bibliographic record of this item
309 =cut
311 sub biblio {
312 my ( $self ) = @_;
313 my $biblio_rs = $self->_result->biblio;
314 return Koha::Biblio->_new_from_dbic( $biblio_rs );
317 =head3 biblioitem
319 my $biblioitem = $item->biblioitem;
321 Return the biblioitem record of this item
323 =cut
325 sub biblioitem {
326 my ( $self ) = @_;
327 my $biblioitem_rs = $self->_result->biblioitem;
328 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
331 =head3 checkout
333 my $checkout = $item->checkout;
335 Return the checkout for this item
337 =cut
339 sub checkout {
340 my ( $self ) = @_;
341 my $checkout_rs = $self->_result->issue;
342 return unless $checkout_rs;
343 return Koha::Checkout->_new_from_dbic( $checkout_rs );
346 =head3 holds
348 my $holds = $item->holds();
349 my $holds = $item->holds($params);
350 my $holds = $item->holds({ found => 'W'});
352 Return holds attached to an item, optionally accept a hashref of params to pass to search
354 =cut
356 sub holds {
357 my ( $self,$params ) = @_;
358 my $holds_rs = $self->_result->reserves->search($params);
359 return Koha::Holds->_new_from_dbic( $holds_rs );
362 =head3 get_transfer
364 my $transfer = $item->get_transfer;
366 Return the transfer if the item is in transit or undef
368 =cut
370 sub get_transfer {
371 my ( $self ) = @_;
372 my $transfer_rs = $self->_result->branchtransfers->search({ datearrived => undef })->first;
373 return unless $transfer_rs;
374 return Koha::Item::Transfer->_new_from_dbic( $transfer_rs );
377 =head3 last_returned_by
379 Gets and sets the last borrower to return an item.
381 Accepts and returns Koha::Patron objects
383 $item->last_returned_by( $borrowernumber );
385 $last_returned_by = $item->last_returned_by();
387 =cut
389 sub last_returned_by {
390 my ( $self, $borrower ) = @_;
392 my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
394 if ($borrower) {
395 return $items_last_returned_by_rs->update_or_create(
396 { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
398 else {
399 unless ( $self->{_last_returned_by} ) {
400 my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
401 if ($result) {
402 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
406 return $self->{_last_returned_by};
410 =head3 can_article_request
412 my $bool = $item->can_article_request( $borrower )
414 Returns true if item can be specifically requested
416 $borrower must be a Koha::Patron object
418 =cut
420 sub can_article_request {
421 my ( $self, $borrower ) = @_;
423 my $rule = $self->article_request_type($borrower);
425 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
426 return q{};
429 =head3 hidden_in_opac
431 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
433 Returns true if item fields match the hidding criteria defined in $rules.
434 Returns false otherwise.
436 Takes HASHref that can have the following parameters:
437 OPTIONAL PARAMETERS:
438 $rules : { <field> => [ value_1, ... ], ... }
440 Note: $rules inherits its structure from the parsed YAML from reading
441 the I<OpacHiddenItems> system preference.
443 =cut
445 sub hidden_in_opac {
446 my ( $self, $params ) = @_;
448 my $rules = $params->{rules} // {};
450 return 1
451 if C4::Context->preference('hidelostitems') and
452 $self->itemlost > 0;
454 my $hidden_in_opac = 0;
456 foreach my $field ( keys %{$rules} ) {
458 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
459 $hidden_in_opac = 1;
460 last;
464 return $hidden_in_opac;
467 =head3 can_be_transferred
469 $item->can_be_transferred({ to => $to_library, from => $from_library })
470 Checks if an item can be transferred to given library.
472 This feature is controlled by two system preferences:
473 UseBranchTransferLimits to enable / disable the feature
474 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
475 for setting the limitations
477 Takes HASHref that can have the following parameters:
478 MANDATORY PARAMETERS:
479 $to : Koha::Library
480 OPTIONAL PARAMETERS:
481 $from : Koha::Library # if not given, item holdingbranch
482 # will be used instead
484 Returns 1 if item can be transferred to $to_library, otherwise 0.
486 To find out whether at least one item of a Koha::Biblio can be transferred, please
487 see Koha::Biblio->can_be_transferred() instead of using this method for
488 multiple items of the same biblio.
490 =cut
492 sub can_be_transferred {
493 my ($self, $params) = @_;
495 my $to = $params->{to};
496 my $from = $params->{from};
498 $to = $to->branchcode;
499 $from = defined $from ? $from->branchcode : $self->holdingbranch;
501 return 1 if $from eq $to; # Transfer to current branch is allowed
502 return 1 unless C4::Context->preference('UseBranchTransferLimits');
504 my $limittype = C4::Context->preference('BranchTransferLimitsType');
505 return Koha::Item::Transfer::Limits->search({
506 toBranch => $to,
507 fromBranch => $from,
508 $limittype => $limittype eq 'itemtype'
509 ? $self->effective_itemtype : $self->ccode
510 })->count ? 0 : 1;
513 =head3 pickup_locations
515 @pickup_locations = $item->pickup_locations( {patron => $patron } )
517 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)
518 and if item can be transferred to each pickup location.
520 =cut
522 sub pickup_locations {
523 my ($self, $params) = @_;
525 my $patron = $params->{patron};
527 my $circ_control_branch =
528 C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
529 my $branchitemrule =
530 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
532 my @libs;
533 if(defined $patron) {
534 return @libs if $branchitemrule->{holdallowed} == 3 && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
535 return @libs if $branchitemrule->{holdallowed} == 1 && $self->home_branch->branchcode ne $patron->branchcode;
538 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
539 @libs = $self->home_branch->get_hold_libraries;
540 push @libs, $self->home_branch unless scalar(@libs) > 0;
541 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
542 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
543 @libs = $plib->get_hold_libraries;
544 push @libs, $self->home_branch unless scalar(@libs) > 0;
545 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
546 push @libs, $self->home_branch;
547 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
548 push @libs, $self->holding_branch;
549 } else {
550 @libs = Koha::Libraries->search({
551 pickup_location => 1
552 }, {
553 order_by => ['branchname']
554 })->as_list;
557 my @pickup_locations;
558 foreach my $library (@libs) {
559 if ($library->pickup_location && $self->can_be_transferred({ to => $library })) {
560 push @pickup_locations, $library;
564 return wantarray ? @pickup_locations : \@pickup_locations;
567 =head3 article_request_type
569 my $type = $item->article_request_type( $borrower )
571 returns 'yes', 'no', 'bib_only', or 'item_only'
573 $borrower must be a Koha::Patron object
575 =cut
577 sub article_request_type {
578 my ( $self, $borrower ) = @_;
580 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
581 my $branchcode =
582 $branch_control eq 'homebranch' ? $self->homebranch
583 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
584 : undef;
585 my $borrowertype = $borrower->categorycode;
586 my $itemtype = $self->effective_itemtype();
587 my $rule = Koha::CirculationRules->get_effective_rule(
589 rule_name => 'article_requests',
590 categorycode => $borrowertype,
591 itemtype => $itemtype,
592 branchcode => $branchcode
596 return q{} unless $rule;
597 return $rule->rule_value || q{}
600 =head3 current_holds
602 =cut
604 sub current_holds {
605 my ( $self ) = @_;
606 my $attributes = { order_by => 'priority' };
607 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
608 my $params = {
609 itemnumber => $self->itemnumber,
610 suspend => 0,
611 -or => [
612 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
613 waitingdate => { '!=' => undef },
616 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
617 return Koha::Holds->_new_from_dbic($hold_rs);
620 =head3 stockrotationitem
622 my $sritem = Koha::Item->stockrotationitem;
624 Returns the stock rotation item associated with the current item.
626 =cut
628 sub stockrotationitem {
629 my ( $self ) = @_;
630 my $rs = $self->_result->stockrotationitem;
631 return 0 if !$rs;
632 return Koha::StockRotationItem->_new_from_dbic( $rs );
635 =head3 add_to_rota
637 my $item = $item->add_to_rota($rota_id);
639 Add this item to the rota identified by $ROTA_ID, which means associating it
640 with the first stage of that rota. Should this item already be associated
641 with a rota, then we will move it to the new rota.
643 =cut
645 sub add_to_rota {
646 my ( $self, $rota_id ) = @_;
647 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
648 return $self;
651 =head3 has_pending_hold
653 my $is_pending_hold = $item->has_pending_hold();
655 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
657 =cut
659 sub has_pending_hold {
660 my ( $self ) = @_;
661 my $pending_hold = $self->_result->tmp_holdsqueues;
662 return $pending_hold->count ? 1: 0;
665 =head3 as_marc_field
667 my $mss = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
668 my $field = $item->as_marc_field({ [ mss => $mss ] });
670 This method returns a MARC::Field object representing the Koha::Item object
671 with the current mappings configuration.
673 =cut
675 sub as_marc_field {
676 my ( $self, $params ) = @_;
678 my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
679 my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
681 my @subfields;
683 my @columns = $self->_result->result_source->columns;
685 foreach my $item_field ( @columns ) {
686 my $mapping = $mss->{ "items.$item_field"}[0];
687 my $tagfield = $mapping->{tagfield};
688 my $tagsubfield = $mapping->{tagsubfield};
689 next if !$tagfield; # TODO: Should we raise an exception instead?
690 # Feels like safe fallback is better
692 push @subfields, $tagsubfield => $self->$item_field
693 if defined $self->$item_field and $item_field ne '';
696 my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
697 push( @subfields, @{$unlinked_item_subfields} )
698 if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
700 my $field;
702 $field = MARC::Field->new(
703 "$item_tag", ' ', ' ', @subfields
704 ) if @subfields;
706 return $field;
709 =head3 renewal_branchcode
711 Returns the branchcode to be recorded in statistics renewal of the item
713 =cut
715 sub renewal_branchcode {
717 my ($self, $params ) = @_;
719 my $interface = C4::Context->interface;
720 my $branchcode;
721 if ( $interface eq 'opac' ){
722 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
723 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
724 $branchcode = 'OPACRenew';
726 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
727 $branchcode = $self->homebranch;
729 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
730 $branchcode = $self->checkout->patron->branchcode;
732 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
733 $branchcode = $self->checkout->branchcode;
735 else {
736 $branchcode = "";
738 } else {
739 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
740 ? C4::Context->userenv->{branch} : $params->{branch};
742 return $branchcode;
745 =head3 to_api_mapping
747 This method returns the mapping for representing a Koha::Item object
748 on the API.
750 =cut
752 sub to_api_mapping {
753 return {
754 itemnumber => 'item_id',
755 biblionumber => 'biblio_id',
756 biblioitemnumber => undef,
757 barcode => 'external_id',
758 dateaccessioned => 'acquisition_date',
759 booksellerid => 'acquisition_source',
760 homebranch => 'home_library_id',
761 price => 'purchase_price',
762 replacementprice => 'replacement_price',
763 replacementpricedate => 'replacement_price_date',
764 datelastborrowed => 'last_checkout_date',
765 datelastseen => 'last_seen_date',
766 stack => undef,
767 notforloan => 'not_for_loan_status',
768 damaged => 'damaged_status',
769 damaged_on => 'damaged_date',
770 itemlost => 'lost_status',
771 itemlost_on => 'lost_date',
772 withdrawn => 'withdrawn',
773 withdrawn_on => 'withdrawn_date',
774 itemcallnumber => 'callnumber',
775 coded_location_qualifier => 'coded_location_qualifier',
776 issues => 'checkouts_count',
777 renewals => 'renewals_count',
778 reserves => 'holds_count',
779 restricted => 'restricted_status',
780 itemnotes => 'public_notes',
781 itemnotes_nonpublic => 'internal_notes',
782 holdingbranch => 'holding_library_id',
783 paidfor => undef,
784 timestamp => 'timestamp',
785 location => 'location',
786 permanent_location => 'permanent_location',
787 onloan => 'checked_out_date',
788 cn_source => 'call_number_source',
789 cn_sort => 'call_number_sort',
790 ccode => 'collection_code',
791 materials => 'materials_notes',
792 uri => 'uri',
793 itype => 'item_type',
794 more_subfields_xml => 'extended_subfields',
795 enumchron => 'serial_issue_number',
796 copynumber => 'copy_number',
797 stocknumber => 'inventory_number',
798 new_status => 'new_status'
802 =head2 Internal methods
804 =head3 _after_item_action_hooks
806 Helper method that takes care of calling all plugin hooks
808 =cut
810 sub _after_item_action_hooks {
811 my ( $self, $params ) = @_;
813 my $action = $params->{action};
815 if ( C4::Context->config("enable_plugins") ) {
817 my @plugins = Koha::Plugins->new->GetPlugins({
818 method => 'after_item_action',
821 if (@plugins) {
823 foreach my $plugin ( @plugins ) {
824 try {
825 $plugin->after_item_action({ action => $action, item => $self, item_id => $self->itemnumber });
827 catch {
828 warn "$_";
835 =head3 _type
837 =cut
839 sub _type {
840 return 'Item';
843 =head1 AUTHOR
845 Kyle M Hall <kyle@bywatersolutions.com>
847 =cut