Bug 28676: Cache and retrieve match_count when searching a cached heading
[koha.git] / Koha / Item.pm
1 package Koha::Item;
2
3 # Copyright ByWater Solutions 2014
4 #
5 # This file is part of Koha.
6 #
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.
11 #
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.
16 #
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>.
19
20 use Modern::Perl;
21
22 use List::MoreUtils qw( any );
23 use Data::Dumper qw( Dumper );
24
25 use Koha::Database;
26 use Koha::DateUtils qw( dt_from_string );
27
28 use C4::Context;
29 use C4::Circulation qw( GetBranchItemRule );
30 use C4::Reserves;
31 use C4::ClassSource qw( GetClassSort );
32 use C4::Log qw( logaction );
33
34 use Koha::Checkouts;
35 use Koha::CirculationRules;
36 use Koha::CoverImages;
37 use Koha::SearchEngine::Indexer;
38 use Koha::Exceptions::Item::Transfer;
39 use Koha::Item::Transfer::Limits;
40 use Koha::Item::Transfers;
41 use Koha::ItemTypes;
42 use Koha::Patrons;
43 use Koha::Plugins;
44 use Koha::Libraries;
45 use Koha::StockRotationItem;
46 use Koha::StockRotationRotas;
47 use Koha::TrackedLinks;
48
49 use base qw(Koha::Object);
50
51 =head1 NAME
52
53 Koha::Item - Koha Item object class
54
55 =head1 API
56
57 =head2 Class methods
58
59 =cut
60
61 =head3 store
62
63     $item->store;
64
65 $params can take an optional 'skip_record_index' parameter.
66 If set, the reindexation process will not happen (index_records not called)
67
68 NOTE: This is a temporary fix to answer a performance issue when lot of items
69 are added (or modified) at the same time.
70 The correct way to fix this is to make the ES reindexation process async.
71 You should not turn it on if you do not understand what it is doing exactly.
72
73 =cut
74
75 sub store {
76     my $self = shift;
77     my $params = @_ ? shift : {};
78
79     my $log_action = $params->{log_action} // 1;
80
81     # We do not want to oblige callers to pass this value
82     # Dev conveniences vs performance?
83     unless ( $self->biblioitemnumber ) {
84         $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
85     }
86
87     # See related changes from C4::Items::AddItem
88     unless ( $self->itype ) {
89         $self->itype($self->biblio->biblioitem->itemtype);
90     }
91
92     my $today  = dt_from_string;
93     my $action = 'create';
94
95     unless ( $self->in_storage ) { #AddItem
96
97         unless ( $self->permanent_location ) {
98             $self->permanent_location($self->location);
99         }
100
101         my $default_location = C4::Context->preference('NewItemsDefaultLocation');
102         unless ( $self->location || !$default_location ) {
103             $self->permanent_location( $self->location || $default_location )
104               unless $self->permanent_location;
105             $self->location($default_location);
106         }
107
108         unless ( $self->replacementpricedate ) {
109             $self->replacementpricedate($today);
110         }
111         unless ( $self->datelastseen ) {
112             $self->datelastseen($today);
113         }
114
115         unless ( $self->dateaccessioned ) {
116             $self->dateaccessioned($today);
117         }
118
119         if (   $self->itemcallnumber
120             or $self->cn_source )
121         {
122             my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
123             $self->cn_sort($cn_sort);
124         }
125
126     } else { # ModItem
127
128         $action = 'modify';
129
130         my %updated_columns = $self->_result->get_dirty_columns;
131         return $self->SUPER::store unless %updated_columns;
132
133         # Retrieve the item for comparison if we need to
134         my $pre_mod_item = (
135                  exists $updated_columns{itemlost}
136               or exists $updated_columns{withdrawn}
137               or exists $updated_columns{damaged}
138         ) ? $self->get_from_storage : undef;
139
140         # Update *_on  fields if needed
141         # FIXME: Why not for AddItem as well?
142         my @fields = qw( itemlost withdrawn damaged );
143         for my $field (@fields) {
144
145             # If the field is defined but empty or 0, we are
146             # removing/unsetting and thus need to clear out
147             # the 'on' field
148             if (   exists $updated_columns{$field}
149                 && defined( $self->$field )
150                 && !$self->$field )
151             {
152                 my $field_on = "${field}_on";
153                 $self->$field_on(undef);
154             }
155             # If the field has changed otherwise, we much update
156             # the 'on' field
157             elsif (exists $updated_columns{$field}
158                 && $updated_columns{$field}
159                 && !$pre_mod_item->$field )
160             {
161                 my $field_on = "${field}_on";
162                 $self->$field_on(
163                     DateTime::Format::MySQL->format_datetime(
164                         dt_from_string()
165                     )
166                 );
167             }
168         }
169
170         if (   exists $updated_columns{itemcallnumber}
171             or exists $updated_columns{cn_source} )
172         {
173             my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
174             $self->cn_sort($cn_sort);
175         }
176
177
178         if (    exists $updated_columns{location}
179             and $self->location ne 'CART'
180             and $self->location ne 'PROC'
181             and not exists $updated_columns{permanent_location} )
182         {
183             $self->permanent_location( $self->location );
184         }
185
186         # If item was lost and has now been found,
187         # reverse any list item charges if necessary.
188         if (    exists $updated_columns{itemlost}
189             and $updated_columns{itemlost} <= 0
190             and $pre_mod_item->itemlost > 0 )
191         {
192             $self->_set_found_trigger($pre_mod_item);
193         }
194
195     }
196
197     unless ( $self->dateaccessioned ) {
198         $self->dateaccessioned($today);
199     }
200
201     my $result = $self->SUPER::store;
202     if ( $log_action && C4::Context->preference("CataloguingLog") ) {
203         $action eq 'create'
204           ? logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
205           : logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper( $self->unblessed ) );
206     }
207     my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
208     $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
209         unless $params->{skip_record_index};
210     $self->get_from_storage->_after_item_action_hooks({ action => $action });
211
212     return $result;
213 }
214
215 =head3 delete
216
217 =cut
218
219 sub delete {
220     my $self = shift;
221     my $params = @_ ? shift : {};
222
223     # FIXME check the item has no current issues
224     # i.e. raise the appropriate exception
225
226     my $result = $self->SUPER::delete;
227
228     my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
229     $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
230         unless $params->{skip_record_index};
231
232     $self->_after_item_action_hooks({ action => 'delete' });
233
234     logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
235       if C4::Context->preference("CataloguingLog");
236
237     return $result;
238 }
239
240 =head3 safe_delete
241
242 =cut
243
244 sub safe_delete {
245     my $self = shift;
246     my $params = @_ ? shift : {};
247
248     my $safe_to_delete = $self->safe_to_delete;
249     return $safe_to_delete unless $safe_to_delete eq '1';
250
251     $self->move_to_deleted;
252
253     return $self->delete($params);
254 }
255
256 =head3 safe_to_delete
257
258 returns 1 if the item is safe to delete,
259
260 "book_on_loan" if the item is checked out,
261
262 "not_same_branch" if the item is blocked by independent branches,
263
264 "book_reserved" if the there are holds aganst the item, or
265
266 "linked_analytics" if the item has linked analytic records.
267
268 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
269
270 =cut
271
272 sub safe_to_delete {
273     my ($self) = @_;
274
275     return "book_on_loan" if $self->checkout;
276
277     return "not_same_branch"
278       if defined C4::Context->userenv
279       and !C4::Context->IsSuperLibrarian()
280       and C4::Context->preference("IndependentBranches")
281       and ( C4::Context->userenv->{branch} ne $self->homebranch );
282
283     # check it doesn't have a waiting reserve
284     return "book_reserved"
285       if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
286
287     return "linked_analytics"
288       if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
289
290     return "last_item_for_hold"
291       if $self->biblio->items->count == 1
292       && $self->biblio->holds->search(
293           {
294               itemnumber => undef,
295           }
296         )->count;
297
298     return 1;
299 }
300
301 =head3 move_to_deleted
302
303 my $is_moved = $item->move_to_deleted;
304
305 Move an item to the deleteditems table.
306 This can be done before deleting an item, to make sure the data are not completely deleted.
307
308 =cut
309
310 sub move_to_deleted {
311     my ($self) = @_;
312     my $item_infos = $self->unblessed;
313     delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
314     return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
315 }
316
317
318 =head3 effective_itemtype
319
320 Returns the itemtype for the item based on whether item level itemtypes are set or not.
321
322 =cut
323
324 sub effective_itemtype {
325     my ( $self ) = @_;
326
327     return $self->_result()->effective_itemtype();
328 }
329
330 =head3 home_branch
331
332 =cut
333
334 sub home_branch {
335     my ($self) = @_;
336
337     $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
338
339     return $self->{_home_branch};
340 }
341
342 =head3 holding_branch
343
344 =cut
345
346 sub holding_branch {
347     my ($self) = @_;
348
349     $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
350
351     return $self->{_holding_branch};
352 }
353
354 =head3 biblio
355
356 my $biblio = $item->biblio;
357
358 Return the bibliographic record of this item
359
360 =cut
361
362 sub biblio {
363     my ( $self ) = @_;
364     my $biblio_rs = $self->_result->biblio;
365     return Koha::Biblio->_new_from_dbic( $biblio_rs );
366 }
367
368 =head3 biblioitem
369
370 my $biblioitem = $item->biblioitem;
371
372 Return the biblioitem record of this item
373
374 =cut
375
376 sub biblioitem {
377     my ( $self ) = @_;
378     my $biblioitem_rs = $self->_result->biblioitem;
379     return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
380 }
381
382 =head3 checkout
383
384 my $checkout = $item->checkout;
385
386 Return the checkout for this item
387
388 =cut
389
390 sub checkout {
391     my ( $self ) = @_;
392     my $checkout_rs = $self->_result->issue;
393     return unless $checkout_rs;
394     return Koha::Checkout->_new_from_dbic( $checkout_rs );
395 }
396
397 =head3 holds
398
399 my $holds = $item->holds();
400 my $holds = $item->holds($params);
401 my $holds = $item->holds({ found => 'W'});
402
403 Return holds attached to an item, optionally accept a hashref of params to pass to search
404
405 =cut
406
407 sub holds {
408     my ( $self,$params ) = @_;
409     my $holds_rs = $self->_result->reserves->search($params);
410     return Koha::Holds->_new_from_dbic( $holds_rs );
411 }
412
413 =head3 request_transfer
414
415   my $transfer = $item->request_transfer(
416     {
417         to     => $to_library,
418         reason => $reason,
419         [ ignore_limits => 0, enqueue => 1, replace => 1 ]
420     }
421   );
422
423 Add a transfer request for this item to the given branch for the given reason.
424
425 An exception will be thrown if the BranchTransferLimits would prevent the requested
426 transfer, unless 'ignore_limits' is passed to override the limits.
427
428 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
429 The caller should catch such cases and retry the transfer request as appropriate passing
430 an appropriate override.
431
432 Overrides
433 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
434 * replace - Used to replace the existing transfer request with your own.
435
436 =cut
437
438 sub request_transfer {
439     my ( $self, $params ) = @_;
440
441     # check for mandatory params
442     my @mandatory = ( 'to', 'reason' );
443     for my $param (@mandatory) {
444         unless ( defined( $params->{$param} ) ) {
445             Koha::Exceptions::MissingParameter->throw(
446                 error => "The $param parameter is mandatory" );
447         }
448     }
449
450     Koha::Exceptions::Item::Transfer::Limit->throw()
451       unless ( $params->{ignore_limits}
452         || $self->can_be_transferred( { to => $params->{to} } ) );
453
454     my $request = $self->get_transfer;
455     Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
456       if ( $request && !$params->{enqueue} && !$params->{replace} );
457
458     $request->cancel( { reason => $params->{reason}, force => 1 } )
459       if ( defined($request) && $params->{replace} );
460
461     my $transfer = Koha::Item::Transfer->new(
462         {
463             itemnumber    => $self->itemnumber,
464             daterequested => dt_from_string,
465             frombranch    => $self->holdingbranch,
466             tobranch      => $params->{to}->branchcode,
467             reason        => $params->{reason},
468             comments      => $params->{comment}
469         }
470     )->store();
471
472     return $transfer;
473 }
474
475 =head3 get_transfer
476
477   my $transfer = $item->get_transfer;
478
479 Return the active transfer request or undef
480
481 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
482 whereby the most recently sent, but not received, transfer will be returned
483 if it exists, otherwise the oldest unsatisfied transfer will be returned.
484
485 This allows for transfers to queue, which is the case for stock rotation and
486 rotating collections where a manual transfer may need to take precedence but
487 we still expect the item to end up at a final location eventually.
488
489 =cut
490
491 sub get_transfer {
492     my ($self) = @_;
493     my $transfer_rs = $self->_result->branchtransfers->search(
494         {
495             datearrived   => undef,
496             datecancelled => undef
497         },
498         {
499             order_by =>
500               [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
501             rows => 1
502         }
503     )->first;
504     return unless $transfer_rs;
505     return Koha::Item::Transfer->_new_from_dbic($transfer_rs);
506 }
507
508 =head3 get_transfers
509
510   my $transfer = $item->get_transfers;
511
512 Return the list of outstanding transfers (i.e requested but not yet cancelled
513 or received).
514
515 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
516 whereby the most recently sent, but not received, transfer will be returned
517 first if it exists, otherwise requests are in oldest to newest request order.
518
519 This allows for transfers to queue, which is the case for stock rotation and
520 rotating collections where a manual transfer may need to take precedence but
521 we still expect the item to end up at a final location eventually.
522
523 =cut
524
525 sub get_transfers {
526     my ($self) = @_;
527     my $transfer_rs = $self->_result->branchtransfers->search(
528         {
529             datearrived   => undef,
530             datecancelled => undef
531         },
532         {
533             order_by =>
534               [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
535         }
536     );
537     return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
538 }
539
540 =head3 last_returned_by
541
542 Gets and sets the last borrower to return an item.
543
544 Accepts and returns Koha::Patron objects
545
546 $item->last_returned_by( $borrowernumber );
547
548 $last_returned_by = $item->last_returned_by();
549
550 =cut
551
552 sub last_returned_by {
553     my ( $self, $borrower ) = @_;
554
555     my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
556
557     if ($borrower) {
558         return $items_last_returned_by_rs->update_or_create(
559             { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
560     }
561     else {
562         unless ( $self->{_last_returned_by} ) {
563             my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
564             if ($result) {
565                 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
566             }
567         }
568
569         return $self->{_last_returned_by};
570     }
571 }
572
573 =head3 can_article_request
574
575 my $bool = $item->can_article_request( $borrower )
576
577 Returns true if item can be specifically requested
578
579 $borrower must be a Koha::Patron object
580
581 =cut
582
583 sub can_article_request {
584     my ( $self, $borrower ) = @_;
585
586     my $rule = $self->article_request_type($borrower);
587
588     return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
589     return q{};
590 }
591
592 =head3 hidden_in_opac
593
594 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
595
596 Returns true if item fields match the hidding criteria defined in $rules.
597 Returns false otherwise.
598
599 Takes HASHref that can have the following parameters:
600     OPTIONAL PARAMETERS:
601     $rules : { <field> => [ value_1, ... ], ... }
602
603 Note: $rules inherits its structure from the parsed YAML from reading
604 the I<OpacHiddenItems> system preference.
605
606 =cut
607
608 sub hidden_in_opac {
609     my ( $self, $params ) = @_;
610
611     my $rules = $params->{rules} // {};
612
613     return 1
614         if C4::Context->preference('hidelostitems') and
615            $self->itemlost > 0;
616
617     my $hidden_in_opac = 0;
618
619     foreach my $field ( keys %{$rules} ) {
620
621         if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
622             $hidden_in_opac = 1;
623             last;
624         }
625     }
626
627     return $hidden_in_opac;
628 }
629
630 =head3 can_be_transferred
631
632 $item->can_be_transferred({ to => $to_library, from => $from_library })
633 Checks if an item can be transferred to given library.
634
635 This feature is controlled by two system preferences:
636 UseBranchTransferLimits to enable / disable the feature
637 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
638                          for setting the limitations
639
640 Takes HASHref that can have the following parameters:
641     MANDATORY PARAMETERS:
642     $to   : Koha::Library
643     OPTIONAL PARAMETERS:
644     $from : Koha::Library  # if not given, item holdingbranch
645                            # will be used instead
646
647 Returns 1 if item can be transferred to $to_library, otherwise 0.
648
649 To find out whether at least one item of a Koha::Biblio can be transferred, please
650 see Koha::Biblio->can_be_transferred() instead of using this method for
651 multiple items of the same biblio.
652
653 =cut
654
655 sub can_be_transferred {
656     my ($self, $params) = @_;
657
658     my $to   = $params->{to};
659     my $from = $params->{from};
660
661     $to   = $to->branchcode;
662     $from = defined $from ? $from->branchcode : $self->holdingbranch;
663
664     return 1 if $from eq $to; # Transfer to current branch is allowed
665     return 1 unless C4::Context->preference('UseBranchTransferLimits');
666
667     my $limittype = C4::Context->preference('BranchTransferLimitsType');
668     return Koha::Item::Transfer::Limits->search({
669         toBranch => $to,
670         fromBranch => $from,
671         $limittype => $limittype eq 'itemtype'
672                         ? $self->effective_itemtype : $self->ccode
673     })->count ? 0 : 1;
674
675 }
676
677 =head3 pickup_locations
678
679 $pickup_locations = $item->pickup_locations( {patron => $patron } )
680
681 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)
682 and if item can be transferred to each pickup location.
683
684 =cut
685
686 sub pickup_locations {
687     my ($self, $params) = @_;
688
689     my $patron = $params->{patron};
690
691     my $circ_control_branch =
692       C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
693     my $branchitemrule =
694       C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
695
696     if(defined $patron) {
697         return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
698         return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
699     }
700
701     my $pickup_libraries = Koha::Libraries->search();
702     if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
703         $pickup_libraries = $self->home_branch->get_hold_libraries;
704     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
705         my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
706         $pickup_libraries = $plib->get_hold_libraries;
707     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
708         $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
709     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
710         $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
711     };
712
713     return $pickup_libraries->search(
714         {
715             pickup_location => 1
716         },
717         {
718             order_by => ['branchname']
719         }
720     ) unless C4::Context->preference('UseBranchTransferLimits');
721
722     my $limittype = C4::Context->preference('BranchTransferLimitsType');
723     my ($ccode, $itype) = (undef, undef);
724     if( $limittype eq 'ccode' ){
725         $ccode = $self->ccode;
726     } else {
727         $itype = $self->itype;
728     }
729     my $limits = Koha::Item::Transfer::Limits->search(
730         {
731             fromBranch => $self->holdingbranch,
732             ccode      => $ccode,
733             itemtype   => $itype,
734         },
735         { columns => ['toBranch'] }
736     );
737
738     return $pickup_libraries->search(
739         {
740             pickup_location => 1,
741             branchcode      => {
742                 '-not_in' => $limits->_resultset->as_query
743             }
744         },
745         {
746             order_by => ['branchname']
747         }
748     );
749 }
750
751 =head3 article_request_type
752
753 my $type = $item->article_request_type( $borrower )
754
755 returns 'yes', 'no', 'bib_only', or 'item_only'
756
757 $borrower must be a Koha::Patron object
758
759 =cut
760
761 sub article_request_type {
762     my ( $self, $borrower ) = @_;
763
764     my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
765     my $branchcode =
766         $branch_control eq 'homebranch'    ? $self->homebranch
767       : $branch_control eq 'holdingbranch' ? $self->holdingbranch
768       :                                      undef;
769     my $borrowertype = $borrower->categorycode;
770     my $itemtype = $self->effective_itemtype();
771     my $rule = Koha::CirculationRules->get_effective_rule(
772         {
773             rule_name    => 'article_requests',
774             categorycode => $borrowertype,
775             itemtype     => $itemtype,
776             branchcode   => $branchcode
777         }
778     );
779
780     return q{} unless $rule;
781     return $rule->rule_value || q{}
782 }
783
784 =head3 current_holds
785
786 =cut
787
788 sub current_holds {
789     my ( $self ) = @_;
790     my $attributes = { order_by => 'priority' };
791     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
792     my $params = {
793         itemnumber => $self->itemnumber,
794         suspend => 0,
795         -or => [
796             reservedate => { '<=' => $dtf->format_date(dt_from_string) },
797             waitingdate => { '!=' => undef },
798         ],
799     };
800     my $hold_rs = $self->_result->reserves->search( $params, $attributes );
801     return Koha::Holds->_new_from_dbic($hold_rs);
802 }
803
804 =head3 stockrotationitem
805
806   my $sritem = Koha::Item->stockrotationitem;
807
808 Returns the stock rotation item associated with the current item.
809
810 =cut
811
812 sub stockrotationitem {
813     my ( $self ) = @_;
814     my $rs = $self->_result->stockrotationitem;
815     return 0 if !$rs;
816     return Koha::StockRotationItem->_new_from_dbic( $rs );
817 }
818
819 =head3 add_to_rota
820
821   my $item = $item->add_to_rota($rota_id);
822
823 Add this item to the rota identified by $ROTA_ID, which means associating it
824 with the first stage of that rota.  Should this item already be associated
825 with a rota, then we will move it to the new rota.
826
827 =cut
828
829 sub add_to_rota {
830     my ( $self, $rota_id ) = @_;
831     Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
832     return $self;
833 }
834
835 =head3 has_pending_hold
836
837   my $is_pending_hold = $item->has_pending_hold();
838
839 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
840
841 =cut
842
843 sub has_pending_hold {
844     my ( $self ) = @_;
845     my $pending_hold = $self->_result->tmp_holdsqueues;
846     return $pending_hold->count ? 1: 0;
847 }
848
849 =head3 as_marc_field
850
851     my $mss   = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
852     my $field = $item->as_marc_field({ [ mss => $mss ] });
853
854 This method returns a MARC::Field object representing the Koha::Item object
855 with the current mappings configuration.
856
857 =cut
858
859 sub as_marc_field {
860     my ( $self, $params ) = @_;
861
862     my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
863     my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
864
865     my @subfields;
866
867     my @columns = $self->_result->result_source->columns;
868
869     foreach my $item_field ( @columns ) {
870         my $mapping = $mss->{ "items.$item_field"}[0];
871         my $tagfield    = $mapping->{tagfield};
872         my $tagsubfield = $mapping->{tagsubfield};
873         next if !$tagfield; # TODO: Should we raise an exception instead?
874                             # Feels like safe fallback is better
875
876         push @subfields, $tagsubfield => $self->$item_field
877             if defined $self->$item_field and $item_field ne '';
878     }
879
880     my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
881     push( @subfields, @{$unlinked_item_subfields} )
882         if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
883
884     my $field;
885
886     $field = MARC::Field->new(
887         "$item_tag", ' ', ' ', @subfields
888     ) if @subfields;
889
890     return $field;
891 }
892
893 =head3 renewal_branchcode
894
895 Returns the branchcode to be recorded in statistics renewal of the item
896
897 =cut
898
899 sub renewal_branchcode {
900
901     my ($self, $params ) = @_;
902
903     my $interface = C4::Context->interface;
904     my $branchcode;
905     if ( $interface eq 'opac' ){
906         my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
907         if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
908             $branchcode = 'OPACRenew';
909         }
910         elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
911             $branchcode = $self->homebranch;
912         }
913         elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
914             $branchcode = $self->checkout->patron->branchcode;
915         }
916         elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
917             $branchcode = $self->checkout->branchcode;
918         }
919         else {
920             $branchcode = "";
921         }
922     } else {
923         $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
924             ? C4::Context->userenv->{branch} : $params->{branch};
925     }
926     return $branchcode;
927 }
928
929 =head3 cover_images
930
931 Return the cover images associated with this item.
932
933 =cut
934
935 sub cover_images {
936     my ( $self ) = @_;
937
938     my $cover_image_rs = $self->_result->cover_images;
939     return unless $cover_image_rs;
940     return Koha::CoverImages->_new_from_dbic($cover_image_rs);
941 }
942
943 =head3 _set_found_trigger
944
945     $self->_set_found_trigger
946
947 Finds the most recent lost item charge for this item and refunds the patron
948 appropriately, taking into account any payments or writeoffs already applied
949 against the charge.
950
951 Internal function, not exported, called only by Koha::Item->store.
952
953 =cut
954
955 sub _set_found_trigger {
956     my ( $self, $pre_mod_item ) = @_;
957
958     ## If item was lost, it has now been found, reverse any list item charges if necessary.
959     my $no_refund_after_days =
960       C4::Context->preference('NoRefundOnLostReturnedItemsAge');
961     if ($no_refund_after_days) {
962         my $today = dt_from_string();
963         my $lost_age_in_days =
964           dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
965           ->in_units('days');
966
967         return $self unless $lost_age_in_days < $no_refund_after_days;
968     }
969
970     my $lostreturn_policy = Koha::CirculationRules->get_lostreturn_policy(
971         {
972             item          => $self,
973             return_branch => C4::Context->userenv
974             ? C4::Context->userenv->{'branch'}
975             : undef,
976         }
977       );
978
979     if ( $lostreturn_policy ) {
980
981         # refund charge made for lost book
982         my $lost_charge = Koha::Account::Lines->search(
983             {
984                 itemnumber      => $self->itemnumber,
985                 debit_type_code => 'LOST',
986                 status          => [ undef, { '<>' => 'FOUND' } ]
987             },
988             {
989                 order_by => { -desc => [ 'date', 'accountlines_id' ] },
990                 rows     => 1
991             }
992         )->single;
993
994         if ( $lost_charge ) {
995
996             my $patron = $lost_charge->patron;
997             if ( $patron ) {
998
999                 my $account = $patron->account;
1000                 my $total_to_refund = 0;
1001
1002                 # Use cases
1003                 if ( $lost_charge->amount > $lost_charge->amountoutstanding ) {
1004
1005                     # some amount has been cancelled. collect the offsets that are not writeoffs
1006                     # this works because the only way to subtract from this kind of a debt is
1007                     # using the UI buttons 'Pay' and 'Write off'
1008                     my $credit_offsets = $lost_charge->debit_offsets(
1009                         {
1010                             'credit_id'               => { '!=' => undef },
1011                             'credit.credit_type_code' => { '!=' => 'Writeoff' }
1012                         },
1013                         { join => 'credit' }
1014                     );
1015
1016                     $total_to_refund = ( $credit_offsets->count > 0 )
1017                       ? $credit_offsets->total * -1    # credits are negative on the DB
1018                       : 0;
1019                 }
1020
1021                 my $credit_total = $lost_charge->amountoutstanding + $total_to_refund;
1022
1023                 my $credit;
1024                 if ( $credit_total > 0 ) {
1025                     my $branchcode =
1026                       C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1027                     $credit = $account->add_credit(
1028                         {
1029                             amount      => $credit_total,
1030                             description => 'Item found ' . $self->itemnumber,
1031                             type        => 'LOST_FOUND',
1032                             interface   => C4::Context->interface,
1033                             library_id  => $branchcode,
1034                             item_id     => $self->itemnumber,
1035                             issue_id    => $lost_charge->issue_id
1036                         }
1037                     );
1038
1039                     $credit->apply( { debits => [$lost_charge] } );
1040                     $self->{_refunded} = 1;
1041                 }
1042
1043                 # Update the account status
1044                 $lost_charge->status('FOUND');
1045                 $lost_charge->store();
1046
1047                 # Reconcile balances if required
1048                 if ( C4::Context->preference('AccountAutoReconcile') ) {
1049                     $account->reconcile_balance;
1050                 }
1051             }
1052         }
1053
1054         # restore fine for lost book
1055         if ( $lostreturn_policy eq 'restore' ) {
1056             my $lost_overdue = Koha::Account::Lines->search(
1057                 {
1058                     itemnumber      => $self->itemnumber,
1059                     debit_type_code => 'OVERDUE',
1060                     status          => 'LOST'
1061                 },
1062                 {
1063                     order_by => { '-desc' => 'date' },
1064                     rows     => 1
1065                 }
1066             )->single;
1067
1068             if ( $lost_overdue ) {
1069
1070                 my $patron = $lost_overdue->patron;
1071                 if ($patron) {
1072                     my $account = $patron->account;
1073
1074                     # Update status of fine
1075                     $lost_overdue->status('FOUND')->store();
1076
1077                     # Find related forgive credit
1078                     my $refund = $lost_overdue->credits(
1079                         {
1080                             credit_type_code => 'FORGIVEN',
1081                             itemnumber       => $self->itemnumber,
1082                             status           => [ { '!=' => 'VOID' }, undef ]
1083                         },
1084                         { order_by => { '-desc' => 'date' }, rows => 1 }
1085                     )->single;
1086
1087                     if ( $refund ) {
1088                         # Revert the forgive credit
1089                         $refund->void({ interface => 'trigger' });
1090                         $self->{_restored} = 1;
1091                     }
1092
1093                     # Reconcile balances if required
1094                     if ( C4::Context->preference('AccountAutoReconcile') ) {
1095                         $account->reconcile_balance;
1096                     }
1097                 }
1098             }
1099         } elsif ( $lostreturn_policy eq 'charge' ) {
1100             $self->{_charge} = 1;
1101         }
1102     }
1103
1104     return $self;
1105 }
1106
1107 =head3 to_api_mapping
1108
1109 This method returns the mapping for representing a Koha::Item object
1110 on the API.
1111
1112 =cut
1113
1114 sub to_api_mapping {
1115     return {
1116         itemnumber               => 'item_id',
1117         biblionumber             => 'biblio_id',
1118         biblioitemnumber         => undef,
1119         barcode                  => 'external_id',
1120         dateaccessioned          => 'acquisition_date',
1121         booksellerid             => 'acquisition_source',
1122         homebranch               => 'home_library_id',
1123         price                    => 'purchase_price',
1124         replacementprice         => 'replacement_price',
1125         replacementpricedate     => 'replacement_price_date',
1126         datelastborrowed         => 'last_checkout_date',
1127         datelastseen             => 'last_seen_date',
1128         stack                    => undef,
1129         notforloan               => 'not_for_loan_status',
1130         damaged                  => 'damaged_status',
1131         damaged_on               => 'damaged_date',
1132         itemlost                 => 'lost_status',
1133         itemlost_on              => 'lost_date',
1134         withdrawn                => 'withdrawn',
1135         withdrawn_on             => 'withdrawn_date',
1136         itemcallnumber           => 'callnumber',
1137         coded_location_qualifier => 'coded_location_qualifier',
1138         issues                   => 'checkouts_count',
1139         renewals                 => 'renewals_count',
1140         reserves                 => 'holds_count',
1141         restricted               => 'restricted_status',
1142         itemnotes                => 'public_notes',
1143         itemnotes_nonpublic      => 'internal_notes',
1144         holdingbranch            => 'holding_library_id',
1145         timestamp                => 'timestamp',
1146         location                 => 'location',
1147         permanent_location       => 'permanent_location',
1148         onloan                   => 'checked_out_date',
1149         cn_source                => 'call_number_source',
1150         cn_sort                  => 'call_number_sort',
1151         ccode                    => 'collection_code',
1152         materials                => 'materials_notes',
1153         uri                      => 'uri',
1154         itype                    => 'item_type',
1155         more_subfields_xml       => 'extended_subfields',
1156         enumchron                => 'serial_issue_number',
1157         copynumber               => 'copy_number',
1158         stocknumber              => 'inventory_number',
1159         new_status               => 'new_status'
1160     };
1161 }
1162
1163 =head3 itemtype
1164
1165     my $itemtype = $item->itemtype;
1166
1167     Returns Koha object for effective itemtype
1168
1169 =cut
1170
1171 sub itemtype {
1172     my ( $self ) = @_;
1173     return Koha::ItemTypes->find( $self->effective_itemtype );
1174 }
1175
1176 =head3 orders
1177
1178   my $orders = $item->orders();
1179
1180 Returns a Koha::Acquisition::Orders object
1181
1182 =cut
1183
1184 sub orders {
1185     my ( $self ) = @_;
1186
1187     my $orders = $self->_result->item_orders;
1188     return Koha::Acquisition::Orders->_new_from_dbic($orders);
1189 }
1190
1191 =head3 tracked_links
1192
1193   my $tracked_links = $item->tracked_links();
1194
1195 Returns a Koha::TrackedLinks object
1196
1197 =cut
1198
1199 sub tracked_links {
1200     my ( $self ) = @_;
1201
1202     my $tracked_links = $self->_result->linktrackers;
1203     return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1204 }
1205
1206 =head3 move_to_biblio
1207
1208   $item->move_to_biblio($to_biblio[, $params]);
1209
1210 Move the item to another biblio and update any references in other tables.
1211
1212 The final optional parameter, C<$params>, is expected to contain the
1213 'skip_record_index' key, which is relayed down to Koha::Item->store.
1214 There it prevents calling index_records, which takes most of the
1215 time in batch adds/deletes. The caller must take care of calling
1216 index_records separately.
1217
1218 $params:
1219     skip_record_index => 1|0
1220
1221 Returns undef if the move failed or the biblionumber of the destination record otherwise
1222
1223 =cut
1224
1225 sub move_to_biblio {
1226     my ( $self, $to_biblio, $params ) = @_;
1227
1228     $params //= {};
1229
1230     return if $self->biblionumber == $to_biblio->biblionumber;
1231
1232     my $from_biblionumber = $self->biblionumber;
1233     my $to_biblionumber = $to_biblio->biblionumber;
1234
1235     # Own biblionumber and biblioitemnumber
1236     $self->set({
1237         biblionumber => $to_biblionumber,
1238         biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1239     })->store({ skip_record_index => $params->{skip_record_index} });
1240
1241     unless ($params->{skip_record_index}) {
1242         my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1243         $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1244     }
1245
1246     # Acquisition orders
1247     $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1248
1249     # Holds
1250     $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1251
1252     # hold_fill_target (there's no Koha object available yet)
1253     my $hold_fill_target = $self->_result->hold_fill_target;
1254     if ($hold_fill_target) {
1255         $hold_fill_target->update({ biblionumber => $to_biblionumber });
1256     }
1257
1258     # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1259     # and can't even fake one since the significant columns are nullable.
1260     my $storage = $self->_result->result_source->storage;
1261     $storage->dbh_do(
1262         sub {
1263             my ($storage, $dbh, @cols) = @_;
1264
1265             $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1266         }
1267     );
1268
1269     # tracked_links
1270     $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1271
1272     return $to_biblionumber;
1273 }
1274
1275 =head2 Internal methods
1276
1277 =head3 _after_item_action_hooks
1278
1279 Helper method that takes care of calling all plugin hooks
1280
1281 =cut
1282
1283 sub _after_item_action_hooks {
1284     my ( $self, $params ) = @_;
1285
1286     my $action = $params->{action};
1287
1288     Koha::Plugins->call(
1289         'after_item_action',
1290         {
1291             action  => $action,
1292             item    => $self,
1293             item_id => $self->itemnumber,
1294         }
1295     );
1296 }
1297
1298 =head3 _type
1299
1300 =cut
1301
1302 sub _type {
1303     return 'Item';
1304 }
1305
1306 =head1 AUTHOR
1307
1308 Kyle M Hall <kyle@bywatersolutions.com>
1309
1310 =cut
1311
1312 1;