Bug 25184: (RM follow-up) Make DB update idempotent
[koha.git] / Koha / Item.pm
blob9560a567cb11a1a3402dff8a5053e09fcca69531
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 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
239 =cut
241 sub safe_to_delete {
242 my ($self) = @_;
244 return "book_on_loan" if $self->checkout;
246 return "not_same_branch"
247 if defined C4::Context->userenv
248 and !C4::Context->IsSuperLibrarian()
249 and C4::Context->preference("IndependentBranches")
250 and ( C4::Context->userenv->{branch} ne $self->homebranch );
252 # check it doesn't have a waiting reserve
253 return "book_reserved"
254 if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
256 return "linked_analytics"
257 if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
259 return "last_item_for_hold"
260 if $self->biblio->items->count == 1
261 && $self->biblio->holds->search(
263 itemnumber => undef,
265 )->count;
267 return 1;
270 =head3 move_to_deleted
272 my $is_moved = $item->move_to_deleted;
274 Move an item to the deleteditems table.
275 This can be done before deleting an item, to make sure the data are not completely deleted.
277 =cut
279 sub move_to_deleted {
280 my ($self) = @_;
281 my $item_infos = $self->unblessed;
282 delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
283 return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
287 =head3 effective_itemtype
289 Returns the itemtype for the item based on whether item level itemtypes are set or not.
291 =cut
293 sub effective_itemtype {
294 my ( $self ) = @_;
296 return $self->_result()->effective_itemtype();
299 =head3 home_branch
301 =cut
303 sub home_branch {
304 my ($self) = @_;
306 $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
308 return $self->{_home_branch};
311 =head3 holding_branch
313 =cut
315 sub holding_branch {
316 my ($self) = @_;
318 $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
320 return $self->{_holding_branch};
323 =head3 biblio
325 my $biblio = $item->biblio;
327 Return the bibliographic record of this item
329 =cut
331 sub biblio {
332 my ( $self ) = @_;
333 my $biblio_rs = $self->_result->biblio;
334 return Koha::Biblio->_new_from_dbic( $biblio_rs );
337 =head3 biblioitem
339 my $biblioitem = $item->biblioitem;
341 Return the biblioitem record of this item
343 =cut
345 sub biblioitem {
346 my ( $self ) = @_;
347 my $biblioitem_rs = $self->_result->biblioitem;
348 return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
351 =head3 checkout
353 my $checkout = $item->checkout;
355 Return the checkout for this item
357 =cut
359 sub checkout {
360 my ( $self ) = @_;
361 my $checkout_rs = $self->_result->issue;
362 return unless $checkout_rs;
363 return Koha::Checkout->_new_from_dbic( $checkout_rs );
366 =head3 holds
368 my $holds = $item->holds();
369 my $holds = $item->holds($params);
370 my $holds = $item->holds({ found => 'W'});
372 Return holds attached to an item, optionally accept a hashref of params to pass to search
374 =cut
376 sub holds {
377 my ( $self,$params ) = @_;
378 my $holds_rs = $self->_result->reserves->search($params);
379 return Koha::Holds->_new_from_dbic( $holds_rs );
382 =head3 get_transfer
384 my $transfer = $item->get_transfer;
386 Return the transfer if the item is in transit or undef
388 =cut
390 sub get_transfer {
391 my ( $self ) = @_;
392 my $transfer_rs = $self->_result->branchtransfers->search({ datearrived => undef })->first;
393 return unless $transfer_rs;
394 return Koha::Item::Transfer->_new_from_dbic( $transfer_rs );
397 =head3 last_returned_by
399 Gets and sets the last borrower to return an item.
401 Accepts and returns Koha::Patron objects
403 $item->last_returned_by( $borrowernumber );
405 $last_returned_by = $item->last_returned_by();
407 =cut
409 sub last_returned_by {
410 my ( $self, $borrower ) = @_;
412 my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
414 if ($borrower) {
415 return $items_last_returned_by_rs->update_or_create(
416 { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
418 else {
419 unless ( $self->{_last_returned_by} ) {
420 my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
421 if ($result) {
422 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
426 return $self->{_last_returned_by};
430 =head3 can_article_request
432 my $bool = $item->can_article_request( $borrower )
434 Returns true if item can be specifically requested
436 $borrower must be a Koha::Patron object
438 =cut
440 sub can_article_request {
441 my ( $self, $borrower ) = @_;
443 my $rule = $self->article_request_type($borrower);
445 return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
446 return q{};
449 =head3 hidden_in_opac
451 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
453 Returns true if item fields match the hidding criteria defined in $rules.
454 Returns false otherwise.
456 Takes HASHref that can have the following parameters:
457 OPTIONAL PARAMETERS:
458 $rules : { <field> => [ value_1, ... ], ... }
460 Note: $rules inherits its structure from the parsed YAML from reading
461 the I<OpacHiddenItems> system preference.
463 =cut
465 sub hidden_in_opac {
466 my ( $self, $params ) = @_;
468 my $rules = $params->{rules} // {};
470 return 1
471 if C4::Context->preference('hidelostitems') and
472 $self->itemlost > 0;
474 my $hidden_in_opac = 0;
476 foreach my $field ( keys %{$rules} ) {
478 if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
479 $hidden_in_opac = 1;
480 last;
484 return $hidden_in_opac;
487 =head3 can_be_transferred
489 $item->can_be_transferred({ to => $to_library, from => $from_library })
490 Checks if an item can be transferred to given library.
492 This feature is controlled by two system preferences:
493 UseBranchTransferLimits to enable / disable the feature
494 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
495 for setting the limitations
497 Takes HASHref that can have the following parameters:
498 MANDATORY PARAMETERS:
499 $to : Koha::Library
500 OPTIONAL PARAMETERS:
501 $from : Koha::Library # if not given, item holdingbranch
502 # will be used instead
504 Returns 1 if item can be transferred to $to_library, otherwise 0.
506 To find out whether at least one item of a Koha::Biblio can be transferred, please
507 see Koha::Biblio->can_be_transferred() instead of using this method for
508 multiple items of the same biblio.
510 =cut
512 sub can_be_transferred {
513 my ($self, $params) = @_;
515 my $to = $params->{to};
516 my $from = $params->{from};
518 $to = $to->branchcode;
519 $from = defined $from ? $from->branchcode : $self->holdingbranch;
521 return 1 if $from eq $to; # Transfer to current branch is allowed
522 return 1 unless C4::Context->preference('UseBranchTransferLimits');
524 my $limittype = C4::Context->preference('BranchTransferLimitsType');
525 return Koha::Item::Transfer::Limits->search({
526 toBranch => $to,
527 fromBranch => $from,
528 $limittype => $limittype eq 'itemtype'
529 ? $self->effective_itemtype : $self->ccode
530 })->count ? 0 : 1;
533 =head3 pickup_locations
535 $pickup_locations = $item->pickup_locations( {patron => $patron } )
537 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)
538 and if item can be transferred to each pickup location.
540 =cut
542 sub pickup_locations {
543 my ($self, $params) = @_;
545 my $patron = $params->{patron};
547 my $circ_control_branch =
548 C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
549 my $branchitemrule =
550 C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
552 my @libs;
553 if(defined $patron) {
554 return \@libs if $branchitemrule->{holdallowed} == 3 && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
555 return \@libs if $branchitemrule->{holdallowed} == 1 && $self->home_branch->branchcode ne $patron->branchcode;
558 if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
559 @libs = $self->home_branch->get_hold_libraries;
560 push @libs, $self->home_branch unless scalar(@libs) > 0;
561 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
562 my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
563 @libs = $plib->get_hold_libraries;
564 push @libs, $self->home_branch unless scalar(@libs) > 0;
565 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
566 push @libs, $self->home_branch;
567 } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
568 push @libs, $self->holding_branch;
569 } else {
570 @libs = Koha::Libraries->search({
571 pickup_location => 1
572 }, {
573 order_by => ['branchname']
574 })->as_list;
577 my @pickup_locations;
578 foreach my $library (@libs) {
579 if ($library->pickup_location && $self->can_be_transferred({ to => $library })) {
580 push @pickup_locations, $library;
584 return \@pickup_locations;
587 =head3 article_request_type
589 my $type = $item->article_request_type( $borrower )
591 returns 'yes', 'no', 'bib_only', or 'item_only'
593 $borrower must be a Koha::Patron object
595 =cut
597 sub article_request_type {
598 my ( $self, $borrower ) = @_;
600 my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
601 my $branchcode =
602 $branch_control eq 'homebranch' ? $self->homebranch
603 : $branch_control eq 'holdingbranch' ? $self->holdingbranch
604 : undef;
605 my $borrowertype = $borrower->categorycode;
606 my $itemtype = $self->effective_itemtype();
607 my $rule = Koha::CirculationRules->get_effective_rule(
609 rule_name => 'article_requests',
610 categorycode => $borrowertype,
611 itemtype => $itemtype,
612 branchcode => $branchcode
616 return q{} unless $rule;
617 return $rule->rule_value || q{}
620 =head3 current_holds
622 =cut
624 sub current_holds {
625 my ( $self ) = @_;
626 my $attributes = { order_by => 'priority' };
627 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
628 my $params = {
629 itemnumber => $self->itemnumber,
630 suspend => 0,
631 -or => [
632 reservedate => { '<=' => $dtf->format_date(dt_from_string) },
633 waitingdate => { '!=' => undef },
636 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
637 return Koha::Holds->_new_from_dbic($hold_rs);
640 =head3 stockrotationitem
642 my $sritem = Koha::Item->stockrotationitem;
644 Returns the stock rotation item associated with the current item.
646 =cut
648 sub stockrotationitem {
649 my ( $self ) = @_;
650 my $rs = $self->_result->stockrotationitem;
651 return 0 if !$rs;
652 return Koha::StockRotationItem->_new_from_dbic( $rs );
655 =head3 add_to_rota
657 my $item = $item->add_to_rota($rota_id);
659 Add this item to the rota identified by $ROTA_ID, which means associating it
660 with the first stage of that rota. Should this item already be associated
661 with a rota, then we will move it to the new rota.
663 =cut
665 sub add_to_rota {
666 my ( $self, $rota_id ) = @_;
667 Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
668 return $self;
671 =head3 has_pending_hold
673 my $is_pending_hold = $item->has_pending_hold();
675 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
677 =cut
679 sub has_pending_hold {
680 my ( $self ) = @_;
681 my $pending_hold = $self->_result->tmp_holdsqueues;
682 return $pending_hold->count ? 1: 0;
685 =head3 as_marc_field
687 my $mss = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
688 my $field = $item->as_marc_field({ [ mss => $mss ] });
690 This method returns a MARC::Field object representing the Koha::Item object
691 with the current mappings configuration.
693 =cut
695 sub as_marc_field {
696 my ( $self, $params ) = @_;
698 my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
699 my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
701 my @subfields;
703 my @columns = $self->_result->result_source->columns;
705 foreach my $item_field ( @columns ) {
706 my $mapping = $mss->{ "items.$item_field"}[0];
707 my $tagfield = $mapping->{tagfield};
708 my $tagsubfield = $mapping->{tagsubfield};
709 next if !$tagfield; # TODO: Should we raise an exception instead?
710 # Feels like safe fallback is better
712 push @subfields, $tagsubfield => $self->$item_field
713 if defined $self->$item_field and $item_field ne '';
716 my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
717 push( @subfields, @{$unlinked_item_subfields} )
718 if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
720 my $field;
722 $field = MARC::Field->new(
723 "$item_tag", ' ', ' ', @subfields
724 ) if @subfields;
726 return $field;
729 =head3 renewal_branchcode
731 Returns the branchcode to be recorded in statistics renewal of the item
733 =cut
735 sub renewal_branchcode {
737 my ($self, $params ) = @_;
739 my $interface = C4::Context->interface;
740 my $branchcode;
741 if ( $interface eq 'opac' ){
742 my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
743 if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
744 $branchcode = 'OPACRenew';
746 elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
747 $branchcode = $self->homebranch;
749 elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
750 $branchcode = $self->checkout->patron->branchcode;
752 elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
753 $branchcode = $self->checkout->branchcode;
755 else {
756 $branchcode = "";
758 } else {
759 $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
760 ? C4::Context->userenv->{branch} : $params->{branch};
762 return $branchcode;
765 =head3 to_api_mapping
767 This method returns the mapping for representing a Koha::Item object
768 on the API.
770 =cut
772 sub to_api_mapping {
773 return {
774 itemnumber => 'item_id',
775 biblionumber => 'biblio_id',
776 biblioitemnumber => undef,
777 barcode => 'external_id',
778 dateaccessioned => 'acquisition_date',
779 booksellerid => 'acquisition_source',
780 homebranch => 'home_library_id',
781 price => 'purchase_price',
782 replacementprice => 'replacement_price',
783 replacementpricedate => 'replacement_price_date',
784 datelastborrowed => 'last_checkout_date',
785 datelastseen => 'last_seen_date',
786 stack => undef,
787 notforloan => 'not_for_loan_status',
788 damaged => 'damaged_status',
789 damaged_on => 'damaged_date',
790 itemlost => 'lost_status',
791 itemlost_on => 'lost_date',
792 withdrawn => 'withdrawn',
793 withdrawn_on => 'withdrawn_date',
794 itemcallnumber => 'callnumber',
795 coded_location_qualifier => 'coded_location_qualifier',
796 issues => 'checkouts_count',
797 renewals => 'renewals_count',
798 reserves => 'holds_count',
799 restricted => 'restricted_status',
800 itemnotes => 'public_notes',
801 itemnotes_nonpublic => 'internal_notes',
802 holdingbranch => 'holding_library_id',
803 paidfor => undef,
804 timestamp => 'timestamp',
805 location => 'location',
806 permanent_location => 'permanent_location',
807 onloan => 'checked_out_date',
808 cn_source => 'call_number_source',
809 cn_sort => 'call_number_sort',
810 ccode => 'collection_code',
811 materials => 'materials_notes',
812 uri => 'uri',
813 itype => 'item_type',
814 more_subfields_xml => 'extended_subfields',
815 enumchron => 'serial_issue_number',
816 copynumber => 'copy_number',
817 stocknumber => 'inventory_number',
818 new_status => 'new_status'
822 =head2 Internal methods
824 =head3 _after_item_action_hooks
826 Helper method that takes care of calling all plugin hooks
828 =cut
830 sub _after_item_action_hooks {
831 my ( $self, $params ) = @_;
833 my $action = $params->{action};
835 if ( C4::Context->config("enable_plugins") ) {
837 my @plugins = Koha::Plugins->new->GetPlugins({
838 method => 'after_item_action',
841 if (@plugins) {
843 foreach my $plugin ( @plugins ) {
844 try {
845 $plugin->after_item_action({ action => $action, item => $self, item_id => $self->itemnumber });
847 catch {
848 warn "$_";
855 =head3 _type
857 =cut
859 sub _type {
860 return 'Item';
863 =head1 AUTHOR
865 Kyle M Hall <kyle@bywatersolutions.com>
867 =cut