Bug 24380: Unit Test
[koha.git] / Koha / Item.pm
blob9e1bca4ef5f08b6b09c3fd12fa1a5bad7090c2c6
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 $item->store;
63 $params can take an optional 'skip_modzebra_update' parameter.
64 If set, the reindexation process will not happen (ModZebra not called)
66 NOTE: This is a temporary fix to answer a performance issue when lot of items
67 are added (or modified) at the same time.
68 The correct way to fix this is to make the ES reindexation process async.
69 You should not turn it on if you do not understand what it is doing exactly.
71 =cut
73 sub store {
74 my $self = shift;
75 my $params = @_ ? shift : {};
77 my $log_action = $params->{log_action} // 1;
79 # We do not want to oblige callers to pass this value
80 # Dev conveniences vs performance?
81 unless ( $self->biblioitemnumber ) {
82 $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
85 # See related changes from C4::Items::AddItem
86 unless ( $self->itype ) {
87 $self->itype($self->biblio->biblioitem->itemtype);
90 my $today = dt_from_string;
91 unless ( $self->in_storage ) { #AddItem
92 unless ( $self->permanent_location ) {
93 $self->permanent_location($self->location);
95 unless ( $self->replacementpricedate ) {
96 $self->replacementpricedate($today);
98 unless ( $self->datelastseen ) {
99 $self->datelastseen($today);
102 unless ( $self->dateaccessioned ) {
103 $self->dateaccessioned($today);
106 if ( $self->itemcallnumber
107 or $self->cn_source )
109 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
110 $self->cn_sort($cn_sort);
113 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
114 unless $params->{skip_modzebra_update};
116 logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
117 if $log_action && C4::Context->preference("CataloguingLog");
119 $self->_after_item_action_hooks({ action => 'create' });
121 } else { # ModItem
123 { # Update *_on fields if needed
124 # Why not for AddItem as well?
125 my @fields = qw( itemlost withdrawn damaged );
127 # Only retrieve the item if we need to set an "on" date field
128 if ( $self->itemlost || $self->withdrawn || $self->damaged ) {
129 my $pre_mod_item = $self->get_from_storage;
130 for my $field (@fields) {
131 if ( $self->$field
132 and not $pre_mod_item->$field )
134 my $field_on = "${field}_on";
135 $self->$field_on(
136 DateTime::Format::MySQL->format_datetime( dt_from_string() )
142 # If the field is defined but empty, we are removing and,
143 # and thus need to clear out the 'on' field as well
144 for my $field (@fields) {
145 if ( defined( $self->$field ) && !$self->$field ) {
146 my $field_on = "${field}_on";
147 $self->$field_on(undef);
152 my %updated_columns = $self->_result->get_dirty_columns;
153 return $self->SUPER::store unless %updated_columns;
155 if ( exists $updated_columns{itemcallnumber}
156 or exists $updated_columns{cn_source} )
158 my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
159 $self->cn_sort($cn_sort);
163 if ( exists $updated_columns{location}
164 and $self->location ne 'CART'
165 and $self->location ne 'PROC'
166 and not exists $updated_columns{permanent_location} )
168 $self->permanent_location( $self->location );
171 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
172 unless $params->{skip_modzebra_update};
174 $self->_after_item_action_hooks({ action => 'modify' });
176 logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper($self->unblessed) )
177 if $log_action && C4::Context->preference("CataloguingLog");
180 unless ( $self->dateaccessioned ) {
181 $self->dateaccessioned($today);
184 return $self->SUPER::store;
187 =head3 delete
189 =cut
191 sub delete {
192 my $self = shift;
193 my $params = @_ ? shift : {};
195 # FIXME check the item has no current issues
196 # i.e. raise the appropriate exception
198 C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" )
199 unless $params->{skip_modzebra_update};
201 $self->_after_item_action_hooks({ action => 'delete' });
203 logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
204 if C4::Context->preference("CataloguingLog");
206 return $self->SUPER::delete;
209 =head3 safe_delete
211 =cut
213 sub safe_delete {
214 my $self = shift;
215 my $params = @_ ? shift : {};
217 my $safe_to_delete = $self->safe_to_delete;
218 return $safe_to_delete unless $safe_to_delete eq '1';
220 $self->move_to_deleted;
222 return $self->delete($params);
225 =head3 safe_to_delete
227 returns 1 if the item is safe to delete,
229 "book_on_loan" if the item is checked out,
231 "not_same_branch" if the item is blocked by independent branches,
233 "book_reserved" if the there are holds aganst the item, or
235 "linked_analytics" if the item has linked analytic records.
237 =cut
239 sub safe_to_delete {
240 my ($self) = @_;
242 return "book_on_loan" if $self->checkout;
244 return "not_same_branch"
245 if defined C4::Context->userenv
246 and !C4::Context->IsSuperLibrarian()
247 and C4::Context->preference("IndependentBranches")
248 and ( C4::Context->userenv->{branch} ne $self->homebranch );
250 # check it doesn't have a waiting reserve
251 return "book_reserved"
252 if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
254 return "linked_analytics"
255 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
257 return 1;
260 =head3 move_to_deleted
262 my $is_moved = $item->move_to_deleted;
264 Move an item to the deleteditems table.
265 This can be done before deleting an item, to make sure the data are not completely deleted.
267 =cut
269 sub move_to_deleted {
270 my ($self) = @_;
271 my $item_infos = $self->unblessed;
272 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
273 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
277 =head3 effective_itemtype
279 Returns the itemtype for the item based on whether item level itemtypes are set or not.
281 =cut
283 sub effective_itemtype {
284 my ( $self ) = @_;
286 return $self->_result()->effective_itemtype();
289 =head3 home_branch
291 =cut
293 sub home_branch {
294 my ($self) = @_;
296 $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
298 return $self->{_home_branch};
301 =head3 holding_branch
303 =cut
305 sub holding_branch {
306 my ($self) = @_;
308 $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
310 return $self->{_holding_branch};
313 =head3 biblio
315 my $biblio = $item->biblio;
317 Return the bibliographic record of this item
319 =cut
321 sub biblio {
322 my ( $self ) = @_;
323 my $biblio_rs = $self->_result->biblio;
324 return Koha::Biblio->_new_from_dbic( $biblio_rs );
327 =head3 biblioitem
329 my $biblioitem = $item->biblioitem;
331 Return the biblioitem record of this item
333 =cut
335 sub biblioitem {
336 my ( $self ) = @_;
337 my $biblioitem_rs = $self->_result->biblioitem;
338 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
341 =head3 checkout
343 my $checkout = $item->checkout;
345 Return the checkout for this item
347 =cut
349 sub checkout {
350 my ( $self ) = @_;
351 my $checkout_rs = $self->_result->issue;
352 return unless $checkout_rs;
353 return Koha::Checkout->_new_from_dbic( $checkout_rs );
356 =head3 holds
358 my $holds = $item->holds();
359 my $holds = $item->holds($params);
360 my $holds = $item->holds({ found => 'W'});
362 Return holds attached to an item, optionally accept a hashref of params to pass to search
364 =cut
366 sub holds {
367 my ( $self,$params ) = @_;
368 my $holds_rs = $self->_result->reserves->search($params);
369 return Koha::Holds->_new_from_dbic( $holds_rs );
372 =head3 get_transfer
374 my $transfer = $item->get_transfer;
376 Return the transfer if the item is in transit or undef
378 =cut
380 sub get_transfer {
381 my ( $self ) = @_;
382 my $transfer_rs = $self->_result->branchtransfers->search({ datearrived => undef })->first;
383 return unless $transfer_rs;
384 return Koha::Item::Transfer->_new_from_dbic( $transfer_rs );
387 =head3 last_returned_by
389 Gets and sets the last borrower to return an item.
391 Accepts and returns Koha::Patron objects
393 $item->last_returned_by( $borrowernumber );
395 $last_returned_by = $item->last_returned_by();
397 =cut
399 sub last_returned_by {
400 my ( $self, $borrower ) = @_;
402 my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
404 if ($borrower) {
405 return $items_last_returned_by_rs->update_or_create(
406 { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
408 else {
409 unless ( $self->{_last_returned_by} ) {
410 my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
411 if ($result) {
412 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
416 return $self->{_last_returned_by};
420 =head3 can_article_request
422 my $bool = $item->can_article_request( $borrower )
424 Returns true if item can be specifically requested
426 $borrower must be a Koha::Patron object
428 =cut
430 sub can_article_request {
431 my ( $self, $borrower ) = @_;
433 my $rule = $self->article_request_type($borrower);
435 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
436 return q{};
439 =head3 hidden_in_opac
441 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
443 Returns true if item fields match the hidding criteria defined in $rules.
444 Returns false otherwise.
446 Takes HASHref that can have the following parameters:
447 OPTIONAL PARAMETERS:
448 $rules : { <field> => [ value_1, ... ], ... }
450 Note: $rules inherits its structure from the parsed YAML from reading
451 the I<OpacHiddenItems> system preference.
453 =cut
455 sub hidden_in_opac {
456 my ( $self, $params ) = @_;
458 my $rules = $params->{rules} // {};
460 return 1
461 if C4::Context->preference('hidelostitems') and
462 $self->itemlost > 0;
464 my $hidden_in_opac = 0;
466 foreach my $field ( keys %{$rules} ) {
468 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
469 $hidden_in_opac = 1;
470 last;
474 return $hidden_in_opac;
477 =head3 can_be_transferred
479 $item->can_be_transferred({ to => $to_library, from => $from_library })
480 Checks if an item can be transferred to given library.
482 This feature is controlled by two system preferences:
483 UseBranchTransferLimits to enable / disable the feature
484 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
485 for setting the limitations
487 Takes HASHref that can have the following parameters:
488 MANDATORY PARAMETERS:
489 $to : Koha::Library
490 OPTIONAL PARAMETERS:
491 $from : Koha::Library # if not given, item holdingbranch
492 # will be used instead
494 Returns 1 if item can be transferred to $to_library, otherwise 0.
496 To find out whether at least one item of a Koha::Biblio can be transferred, please
497 see Koha::Biblio->can_be_transferred() instead of using this method for
498 multiple items of the same biblio.
500 =cut
502 sub can_be_transferred {
503 my ($self, $params) = @_;
505 my $to = $params->{to};
506 my $from = $params->{from};
508 $to = $to->branchcode;
509 $from = defined $from ? $from->branchcode : $self->holdingbranch;
511 return 1 if $from eq $to; # Transfer to current branch is allowed
512 return 1 unless C4::Context->preference('UseBranchTransferLimits');
514 my $limittype = C4::Context->preference('BranchTransferLimitsType');
515 return Koha::Item::Transfer::Limits->search({
516 toBranch => $to,
517 fromBranch => $from,
518 $limittype => $limittype eq 'itemtype'
519 ? $self->effective_itemtype : $self->ccode
520 })->count ? 0 : 1;
523 =head3 pickup_locations
525 @pickup_locations = $item->pickup_locations( {patron => $patron } )
527 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)
528 and if item can be transferred to each pickup location.
530 =cut
532 sub pickup_locations {
533 my ($self, $params) = @_;
535 my $patron = $params->{patron};
537 my $circ_control_branch =
538 C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
539 my $branchitemrule =
540 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
542 my @libs;
543 if(defined $patron) {
544 return @libs if $branchitemrule->{holdallowed} == 3 && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
545 return @libs if $branchitemrule->{holdallowed} == 1 && $self->home_branch->branchcode ne $patron->branchcode;
548 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
549 @libs = $self->home_branch->get_hold_libraries;
550 push @libs, $self->home_branch unless scalar(@libs) > 0;
551 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
552 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
553 @libs = $plib->get_hold_libraries;
554 push @libs, $self->home_branch unless scalar(@libs) > 0;
555 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
556 push @libs, $self->home_branch;
557 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
558 push @libs, $self->holding_branch;
559 } else {
560 @libs = Koha::Libraries->search({
561 pickup_location => 1
562 }, {
563 order_by => ['branchname']
564 })->as_list;
567 my @pickup_locations;
568 foreach my $library (@libs) {
569 if ($library->pickup_location && $self->can_be_transferred({ to => $library })) {
570 push @pickup_locations, $library;
574 return wantarray ? @pickup_locations : \@pickup_locations;
577 =head3 article_request_type
579 my $type = $item->article_request_type( $borrower )
581 returns 'yes', 'no', 'bib_only', or 'item_only'
583 $borrower must be a Koha::Patron object
585 =cut
587 sub article_request_type {
588 my ( $self, $borrower ) = @_;
590 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
591 my $branchcode =
592 $branch_control eq 'homebranch' ? $self->homebranch
593 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
594 : undef;
595 my $borrowertype = $borrower->categorycode;
596 my $itemtype = $self->effective_itemtype();
597 my $rule = Koha::CirculationRules->get_effective_rule(
599 rule_name => 'article_requests',
600 categorycode => $borrowertype,
601 itemtype => $itemtype,
602 branchcode => $branchcode
606 return q{} unless $rule;
607 return $rule->rule_value || q{}
610 =head3 current_holds
612 =cut
614 sub current_holds {
615 my ( $self ) = @_;
616 my $attributes = { order_by => 'priority' };
617 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
618 my $params = {
619 itemnumber => $self->itemnumber,
620 suspend => 0,
621 -or => [
622 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
623 waitingdate => { '!=' => undef },
626 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
627 return Koha::Holds->_new_from_dbic($hold_rs);
630 =head3 stockrotationitem
632 my $sritem = Koha::Item->stockrotationitem;
634 Returns the stock rotation item associated with the current item.
636 =cut
638 sub stockrotationitem {
639 my ( $self ) = @_;
640 my $rs = $self->_result->stockrotationitem;
641 return 0 if !$rs;
642 return Koha::StockRotationItem->_new_from_dbic( $rs );
645 =head3 add_to_rota
647 my $item = $item->add_to_rota($rota_id);
649 Add this item to the rota identified by $ROTA_ID, which means associating it
650 with the first stage of that rota. Should this item already be associated
651 with a rota, then we will move it to the new rota.
653 =cut
655 sub add_to_rota {
656 my ( $self, $rota_id ) = @_;
657 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
658 return $self;
661 =head3 has_pending_hold
663 my $is_pending_hold = $item->has_pending_hold();
665 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
667 =cut
669 sub has_pending_hold {
670 my ( $self ) = @_;
671 my $pending_hold = $self->_result->tmp_holdsqueues;
672 return $pending_hold->count ? 1: 0;
675 =head3 as_marc_field
677 my $mss = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
678 my $field = $item->as_marc_field({ [ mss => $mss ] });
680 This method returns a MARC::Field object representing the Koha::Item object
681 with the current mappings configuration.
683 =cut
685 sub as_marc_field {
686 my ( $self, $params ) = @_;
688 my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
689 my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
691 my @subfields;
693 my @columns = $self->_result->result_source->columns;
695 foreach my $item_field ( @columns ) {
696 my $mapping = $mss->{ "items.$item_field"}[0];
697 my $tagfield = $mapping->{tagfield};
698 my $tagsubfield = $mapping->{tagsubfield};
699 next if !$tagfield; # TODO: Should we raise an exception instead?
700 # Feels like safe fallback is better
702 push @subfields, $tagsubfield => $self->$item_field
703 if defined $self->$item_field and $item_field ne '';
706 my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
707 push( @subfields, @{$unlinked_item_subfields} )
708 if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
710 my $field;
712 $field = MARC::Field->new(
713 "$item_tag", ' ', ' ', @subfields
714 ) if @subfields;
716 return $field;
719 =head3 renewal_branchcode
721 Returns the branchcode to be recorded in statistics renewal of the item
723 =cut
725 sub renewal_branchcode {
727 my ($self, $params ) = @_;
729 my $interface = C4::Context->interface;
730 my $branchcode;
731 if ( $interface eq 'opac' ){
732 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
733 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
734 $branchcode = 'OPACRenew';
736 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
737 $branchcode = $self->homebranch;
739 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
740 $branchcode = $self->checkout->patron->branchcode;
742 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
743 $branchcode = $self->checkout->branchcode;
745 else {
746 $branchcode = "";
748 } else {
749 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
750 ? C4::Context->userenv->{branch} : $params->{branch};
752 return $branchcode;
755 =head3 to_api_mapping
757 This method returns the mapping for representing a Koha::Item object
758 on the API.
760 =cut
762 sub to_api_mapping {
763 return {
764 itemnumber => 'item_id',
765 biblionumber => 'biblio_id',
766 biblioitemnumber => undef,
767 barcode => 'external_id',
768 dateaccessioned => 'acquisition_date',
769 booksellerid => 'acquisition_source',
770 homebranch => 'home_library_id',
771 price => 'purchase_price',
772 replacementprice => 'replacement_price',
773 replacementpricedate => 'replacement_price_date',
774 datelastborrowed => 'last_checkout_date',
775 datelastseen => 'last_seen_date',
776 stack => undef,
777 notforloan => 'not_for_loan_status',
778 damaged => 'damaged_status',
779 damaged_on => 'damaged_date',
780 itemlost => 'lost_status',
781 itemlost_on => 'lost_date',
782 withdrawn => 'withdrawn',
783 withdrawn_on => 'withdrawn_date',
784 itemcallnumber => 'callnumber',
785 coded_location_qualifier => 'coded_location_qualifier',
786 issues => 'checkouts_count',
787 renewals => 'renewals_count',
788 reserves => 'holds_count',
789 restricted => 'restricted_status',
790 itemnotes => 'public_notes',
791 itemnotes_nonpublic => 'internal_notes',
792 holdingbranch => 'holding_library_id',
793 paidfor => undef,
794 timestamp => 'timestamp',
795 location => 'location',
796 permanent_location => 'permanent_location',
797 onloan => 'checked_out_date',
798 cn_source => 'call_number_source',
799 cn_sort => 'call_number_sort',
800 ccode => 'collection_code',
801 materials => 'materials_notes',
802 uri => 'uri',
803 itype => 'item_type',
804 more_subfields_xml => 'extended_subfields',
805 enumchron => 'serial_issue_number',
806 copynumber => 'copy_number',
807 stocknumber => 'inventory_number',
808 new_status => 'new_status'
812 =head2 Internal methods
814 =head3 _after_item_action_hooks
816 Helper method that takes care of calling all plugin hooks
818 =cut
820 sub _after_item_action_hooks {
821 my ( $self, $params ) = @_;
823 my $action = $params->{action};
825 if ( C4::Context->config("enable_plugins") ) {
827 my @plugins = Koha::Plugins->new->GetPlugins({
828 method => 'after_item_action',
831 if (@plugins) {
833 foreach my $plugin ( @plugins ) {
834 try {
835 $plugin->after_item_action({ action => $action, item => $self, item_id => $self->itemnumber });
837 catch {
838 warn "$_";
845 =head3 _type
847 =cut
849 sub _type {
850 return 'Item';
853 =head1 AUTHOR
855 Kyle M Hall <kyle@bywatersolutions.com>
857 =cut