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