Bug 17600: Standardize our EXPORT_OK
[srvgit] / 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
48 use base qw(Koha::Object);
49
50 =head1 NAME
51
52 Koha::Item - Koha Item object class
53
54 =head1 API
55
56 =head2 Class methods
57
58 =cut
59
60 =head3 store
61
62     $item->store;
63
64 $params can take an optional 'skip_record_index' parameter.
65 If set, the reindexation process will not happen (index_records not called)
66
67 NOTE: This is a temporary fix to answer a performance issue when lot of items
68 are added (or modified) at the same time.
69 The correct way to fix this is to make the ES reindexation process async.
70 You should not turn it on if you do not understand what it is doing exactly.
71
72 =cut
73
74 sub store {
75     my $self = shift;
76     my $params = @_ ? shift : {};
77
78     my $log_action = $params->{log_action} // 1;
79
80     # We do not want to oblige callers to pass this value
81     # Dev conveniences vs performance?
82     unless ( $self->biblioitemnumber ) {
83         $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
84     }
85
86     # See related changes from C4::Items::AddItem
87     unless ( $self->itype ) {
88         $self->itype($self->biblio->biblioitem->itemtype);
89     }
90
91     my $today  = dt_from_string;
92     my $action = 'create';
93
94     unless ( $self->in_storage ) { #AddItem
95
96         unless ( $self->permanent_location ) {
97             $self->permanent_location($self->location);
98         }
99
100         my $default_location = C4::Context->preference('NewItemsDefaultLocation');
101         unless ( $self->location || !$default_location ) {
102             $self->permanent_location( $self->location || $default_location )
103               unless $self->permanent_location;
104             $self->location($default_location);
105         }
106
107         unless ( $self->replacementpricedate ) {
108             $self->replacementpricedate($today);
109         }
110         unless ( $self->datelastseen ) {
111             $self->datelastseen($today);
112         }
113
114         unless ( $self->dateaccessioned ) {
115             $self->dateaccessioned($today);
116         }
117
118         if (   $self->itemcallnumber
119             or $self->cn_source )
120         {
121             my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
122             $self->cn_sort($cn_sort);
123         }
124
125     } else { # ModItem
126
127         $action = 'modify';
128
129         my %updated_columns = $self->_result->get_dirty_columns;
130         return $self->SUPER::store unless %updated_columns;
131
132         # Retrieve the item for comparison if we need to
133         my $pre_mod_item = (
134                  exists $updated_columns{itemlost}
135               or exists $updated_columns{withdrawn}
136               or exists $updated_columns{damaged}
137         ) ? $self->get_from_storage : undef;
138
139         # Update *_on  fields if needed
140         # FIXME: Why not for AddItem as well?
141         my @fields = qw( itemlost withdrawn damaged );
142         for my $field (@fields) {
143
144             # If the field is defined but empty or 0, we are
145             # removing/unsetting and thus need to clear out
146             # the 'on' field
147             if (   exists $updated_columns{$field}
148                 && defined( $self->$field )
149                 && !$self->$field )
150             {
151                 my $field_on = "${field}_on";
152                 $self->$field_on(undef);
153             }
154             # If the field has changed otherwise, we much update
155             # the 'on' field
156             elsif (exists $updated_columns{$field}
157                 && $updated_columns{$field}
158                 && !$pre_mod_item->$field )
159             {
160                 my $field_on = "${field}_on";
161                 $self->$field_on(
162                     DateTime::Format::MySQL->format_datetime(
163                         dt_from_string()
164                     )
165                 );
166             }
167         }
168
169         if (   exists $updated_columns{itemcallnumber}
170             or exists $updated_columns{cn_source} )
171         {
172             my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
173             $self->cn_sort($cn_sort);
174         }
175
176
177         if (    exists $updated_columns{location}
178             and $self->location ne 'CART'
179             and $self->location ne 'PROC'
180             and not exists $updated_columns{permanent_location} )
181         {
182             $self->permanent_location( $self->location );
183         }
184
185         # If item was lost and has now been found,
186         # reverse any list item charges if necessary.
187         if (    exists $updated_columns{itemlost}
188             and $updated_columns{itemlost} <= 0
189             and $pre_mod_item->itemlost > 0 )
190         {
191             $self->_set_found_trigger($pre_mod_item);
192         }
193
194     }
195
196     unless ( $self->dateaccessioned ) {
197         $self->dateaccessioned($today);
198     }
199
200     my $result = $self->SUPER::store;
201     if ( $log_action && C4::Context->preference("CataloguingLog") ) {
202         $action eq 'create'
203           ? logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
204           : logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper( $self->unblessed ) );
205     }
206     my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
207     $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
208         unless $params->{skip_record_index};
209     $self->get_from_storage->_after_item_action_hooks({ action => $action });
210
211     return $result;
212 }
213
214 =head3 delete
215
216 =cut
217
218 sub delete {
219     my $self = shift;
220     my $params = @_ ? shift : {};
221
222     # FIXME check the item has no current issues
223     # i.e. raise the appropriate exception
224
225     my $result = $self->SUPER::delete;
226
227     my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
228     $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
229         unless $params->{skip_record_index};
230
231     $self->_after_item_action_hooks({ action => 'delete' });
232
233     logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
234       if C4::Context->preference("CataloguingLog");
235
236     return $result;
237 }
238
239 =head3 safe_delete
240
241 =cut
242
243 sub safe_delete {
244     my $self = shift;
245     my $params = @_ ? shift : {};
246
247     my $safe_to_delete = $self->safe_to_delete;
248     return $safe_to_delete unless $safe_to_delete eq '1';
249
250     $self->move_to_deleted;
251
252     return $self->delete($params);
253 }
254
255 =head3 safe_to_delete
256
257 returns 1 if the item is safe to delete,
258
259 "book_on_loan" if the item is checked out,
260
261 "not_same_branch" if the item is blocked by independent branches,
262
263 "book_reserved" if the there are holds aganst the item, or
264
265 "linked_analytics" if the item has linked analytic records.
266
267 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
268
269 =cut
270
271 sub safe_to_delete {
272     my ($self) = @_;
273
274     return "book_on_loan" if $self->checkout;
275
276     return "not_same_branch"
277       if defined C4::Context->userenv
278       and !C4::Context->IsSuperLibrarian()
279       and C4::Context->preference("IndependentBranches")
280       and ( C4::Context->userenv->{branch} ne $self->homebranch );
281
282     # check it doesn't have a waiting reserve
283     return "book_reserved"
284       if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
285
286     return "linked_analytics"
287       if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
288
289     return "last_item_for_hold"
290       if $self->biblio->items->count == 1
291       && $self->biblio->holds->search(
292           {
293               itemnumber => undef,
294           }
295         )->count;
296
297     return 1;
298 }
299
300 =head3 move_to_deleted
301
302 my $is_moved = $item->move_to_deleted;
303
304 Move an item to the deleteditems table.
305 This can be done before deleting an item, to make sure the data are not completely deleted.
306
307 =cut
308
309 sub move_to_deleted {
310     my ($self) = @_;
311     my $item_infos = $self->unblessed;
312     delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
313     return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
314 }
315
316
317 =head3 effective_itemtype
318
319 Returns the itemtype for the item based on whether item level itemtypes are set or not.
320
321 =cut
322
323 sub effective_itemtype {
324     my ( $self ) = @_;
325
326     return $self->_result()->effective_itemtype();
327 }
328
329 =head3 home_branch
330
331 =cut
332
333 sub home_branch {
334     my ($self) = @_;
335
336     $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
337
338     return $self->{_home_branch};
339 }
340
341 =head3 holding_branch
342
343 =cut
344
345 sub holding_branch {
346     my ($self) = @_;
347
348     $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
349
350     return $self->{_holding_branch};
351 }
352
353 =head3 biblio
354
355 my $biblio = $item->biblio;
356
357 Return the bibliographic record of this item
358
359 =cut
360
361 sub biblio {
362     my ( $self ) = @_;
363     my $biblio_rs = $self->_result->biblio;
364     return Koha::Biblio->_new_from_dbic( $biblio_rs );
365 }
366
367 =head3 biblioitem
368
369 my $biblioitem = $item->biblioitem;
370
371 Return the biblioitem record of this item
372
373 =cut
374
375 sub biblioitem {
376     my ( $self ) = @_;
377     my $biblioitem_rs = $self->_result->biblioitem;
378     return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
379 }
380
381 =head3 checkout
382
383 my $checkout = $item->checkout;
384
385 Return the checkout for this item
386
387 =cut
388
389 sub checkout {
390     my ( $self ) = @_;
391     my $checkout_rs = $self->_result->issue;
392     return unless $checkout_rs;
393     return Koha::Checkout->_new_from_dbic( $checkout_rs );
394 }
395
396 =head3 holds
397
398 my $holds = $item->holds();
399 my $holds = $item->holds($params);
400 my $holds = $item->holds({ found => 'W'});
401
402 Return holds attached to an item, optionally accept a hashref of params to pass to search
403
404 =cut
405
406 sub holds {
407     my ( $self,$params ) = @_;
408     my $holds_rs = $self->_result->reserves->search($params);
409     return Koha::Holds->_new_from_dbic( $holds_rs );
410 }
411
412 =head3 request_transfer
413
414   my $transfer = $item->request_transfer(
415     {
416         to     => $to_library,
417         reason => $reason,
418         [ ignore_limits => 0, enqueue => 1, replace => 1 ]
419     }
420   );
421
422 Add a transfer request for this item to the given branch for the given reason.
423
424 An exception will be thrown if the BranchTransferLimits would prevent the requested
425 transfer, unless 'ignore_limits' is passed to override the limits.
426
427 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
428 The caller should catch such cases and retry the transfer request as appropriate passing
429 an appropriate override.
430
431 Overrides
432 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
433 * replace - Used to replace the existing transfer request with your own.
434
435 =cut
436
437 sub request_transfer {
438     my ( $self, $params ) = @_;
439
440     # check for mandatory params
441     my @mandatory = ( 'to', 'reason' );
442     for my $param (@mandatory) {
443         unless ( defined( $params->{$param} ) ) {
444             Koha::Exceptions::MissingParameter->throw(
445                 error => "The $param parameter is mandatory" );
446         }
447     }
448
449     Koha::Exceptions::Item::Transfer::Limit->throw()
450       unless ( $params->{ignore_limits}
451         || $self->can_be_transferred( { to => $params->{to} } ) );
452
453     my $request = $self->get_transfer;
454     Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
455       if ( $request && !$params->{enqueue} && !$params->{replace} );
456
457     $request->cancel( { reason => $params->{reason}, force => 1 } )
458       if ( defined($request) && $params->{replace} );
459
460     my $transfer = Koha::Item::Transfer->new(
461         {
462             itemnumber    => $self->itemnumber,
463             daterequested => dt_from_string,
464             frombranch    => $self->holdingbranch,
465             tobranch      => $params->{to}->branchcode,
466             reason        => $params->{reason},
467             comments      => $params->{comment}
468         }
469     )->store();
470
471     return $transfer;
472 }
473
474 =head3 get_transfer
475
476   my $transfer = $item->get_transfer;
477
478 Return the active transfer request or undef
479
480 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
481 whereby the most recently sent, but not received, transfer will be returned
482 if it exists, otherwise the oldest unsatisfied transfer will be returned.
483
484 This allows for transfers to queue, which is the case for stock rotation and
485 rotating collections where a manual transfer may need to take precedence but
486 we still expect the item to end up at a final location eventually.
487
488 =cut
489
490 sub get_transfer {
491     my ($self) = @_;
492     my $transfer_rs = $self->_result->branchtransfers->search(
493         {
494             datearrived   => undef,
495             datecancelled => undef
496         },
497         {
498             order_by =>
499               [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
500             rows => 1
501         }
502     )->first;
503     return unless $transfer_rs;
504     return Koha::Item::Transfer->_new_from_dbic($transfer_rs);
505 }
506
507 =head3 get_transfers
508
509   my $transfer = $item->get_transfers;
510
511 Return the list of outstanding transfers (i.e requested but not yet cancelled
512 or received).
513
514 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
515 whereby the most recently sent, but not received, transfer will be returned
516 first if it exists, otherwise requests are in oldest to newest request order.
517
518 This allows for transfers to queue, which is the case for stock rotation and
519 rotating collections where a manual transfer may need to take precedence but
520 we still expect the item to end up at a final location eventually.
521
522 =cut
523
524 sub get_transfers {
525     my ($self) = @_;
526     my $transfer_rs = $self->_result->branchtransfers->search(
527         {
528             datearrived   => undef,
529             datecancelled => undef
530         },
531         {
532             order_by =>
533               [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
534         }
535     );
536     return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
537 }
538
539 =head3 last_returned_by
540
541 Gets and sets the last borrower to return an item.
542
543 Accepts and returns Koha::Patron objects
544
545 $item->last_returned_by( $borrowernumber );
546
547 $last_returned_by = $item->last_returned_by();
548
549 =cut
550
551 sub last_returned_by {
552     my ( $self, $borrower ) = @_;
553
554     my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
555
556     if ($borrower) {
557         return $items_last_returned_by_rs->update_or_create(
558             { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
559     }
560     else {
561         unless ( $self->{_last_returned_by} ) {
562             my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
563             if ($result) {
564                 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
565             }
566         }
567
568         return $self->{_last_returned_by};
569     }
570 }
571
572 =head3 can_article_request
573
574 my $bool = $item->can_article_request( $borrower )
575
576 Returns true if item can be specifically requested
577
578 $borrower must be a Koha::Patron object
579
580 =cut
581
582 sub can_article_request {
583     my ( $self, $borrower ) = @_;
584
585     my $rule = $self->article_request_type($borrower);
586
587     return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
588     return q{};
589 }
590
591 =head3 hidden_in_opac
592
593 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
594
595 Returns true if item fields match the hidding criteria defined in $rules.
596 Returns false otherwise.
597
598 Takes HASHref that can have the following parameters:
599     OPTIONAL PARAMETERS:
600     $rules : { <field> => [ value_1, ... ], ... }
601
602 Note: $rules inherits its structure from the parsed YAML from reading
603 the I<OpacHiddenItems> system preference.
604
605 =cut
606
607 sub hidden_in_opac {
608     my ( $self, $params ) = @_;
609
610     my $rules = $params->{rules} // {};
611
612     return 1
613         if C4::Context->preference('hidelostitems') and
614            $self->itemlost > 0;
615
616     my $hidden_in_opac = 0;
617
618     foreach my $field ( keys %{$rules} ) {
619
620         if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
621             $hidden_in_opac = 1;
622             last;
623         }
624     }
625
626     return $hidden_in_opac;
627 }
628
629 =head3 can_be_transferred
630
631 $item->can_be_transferred({ to => $to_library, from => $from_library })
632 Checks if an item can be transferred to given library.
633
634 This feature is controlled by two system preferences:
635 UseBranchTransferLimits to enable / disable the feature
636 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
637                          for setting the limitations
638
639 Takes HASHref that can have the following parameters:
640     MANDATORY PARAMETERS:
641     $to   : Koha::Library
642     OPTIONAL PARAMETERS:
643     $from : Koha::Library  # if not given, item holdingbranch
644                            # will be used instead
645
646 Returns 1 if item can be transferred to $to_library, otherwise 0.
647
648 To find out whether at least one item of a Koha::Biblio can be transferred, please
649 see Koha::Biblio->can_be_transferred() instead of using this method for
650 multiple items of the same biblio.
651
652 =cut
653
654 sub can_be_transferred {
655     my ($self, $params) = @_;
656
657     my $to   = $params->{to};
658     my $from = $params->{from};
659
660     $to   = $to->branchcode;
661     $from = defined $from ? $from->branchcode : $self->holdingbranch;
662
663     return 1 if $from eq $to; # Transfer to current branch is allowed
664     return 1 unless C4::Context->preference('UseBranchTransferLimits');
665
666     my $limittype = C4::Context->preference('BranchTransferLimitsType');
667     return Koha::Item::Transfer::Limits->search({
668         toBranch => $to,
669         fromBranch => $from,
670         $limittype => $limittype eq 'itemtype'
671                         ? $self->effective_itemtype : $self->ccode
672     })->count ? 0 : 1;
673
674 }
675
676 =head3 pickup_locations
677
678 $pickup_locations = $item->pickup_locations( {patron => $patron } )
679
680 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)
681 and if item can be transferred to each pickup location.
682
683 =cut
684
685 sub pickup_locations {
686     my ($self, $params) = @_;
687
688     my $patron = $params->{patron};
689
690     my $circ_control_branch =
691       C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
692     my $branchitemrule =
693       C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
694
695     if(defined $patron) {
696         return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
697         return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
698     }
699
700     my $pickup_libraries = Koha::Libraries->search();
701     if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
702         $pickup_libraries = $self->home_branch->get_hold_libraries;
703     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
704         my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
705         $pickup_libraries = $plib->get_hold_libraries;
706     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
707         $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
708     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
709         $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
710     };
711
712     return $pickup_libraries->search(
713         {
714             pickup_location => 1
715         },
716         {
717             order_by => ['branchname']
718         }
719     ) unless C4::Context->preference('UseBranchTransferLimits');
720
721     my $limittype = C4::Context->preference('BranchTransferLimitsType');
722     my ($ccode, $itype) = (undef, undef);
723     if( $limittype eq 'ccode' ){
724         $ccode = $self->ccode;
725     } else {
726         $itype = $self->itype;
727     }
728     my $limits = Koha::Item::Transfer::Limits->search(
729         {
730             fromBranch => $self->holdingbranch,
731             ccode      => $ccode,
732             itemtype   => $itype,
733         },
734         { columns => ['toBranch'] }
735     );
736
737     return $pickup_libraries->search(
738         {
739             pickup_location => 1,
740             branchcode      => {
741                 '-not_in' => $limits->_resultset->as_query
742             }
743         },
744         {
745             order_by => ['branchname']
746         }
747     );
748 }
749
750 =head3 article_request_type
751
752 my $type = $item->article_request_type( $borrower )
753
754 returns 'yes', 'no', 'bib_only', or 'item_only'
755
756 $borrower must be a Koha::Patron object
757
758 =cut
759
760 sub article_request_type {
761     my ( $self, $borrower ) = @_;
762
763     my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
764     my $branchcode =
765         $branch_control eq 'homebranch'    ? $self->homebranch
766       : $branch_control eq 'holdingbranch' ? $self->holdingbranch
767       :                                      undef;
768     my $borrowertype = $borrower->categorycode;
769     my $itemtype = $self->effective_itemtype();
770     my $rule = Koha::CirculationRules->get_effective_rule(
771         {
772             rule_name    => 'article_requests',
773             categorycode => $borrowertype,
774             itemtype     => $itemtype,
775             branchcode   => $branchcode
776         }
777     );
778
779     return q{} unless $rule;
780     return $rule->rule_value || q{}
781 }
782
783 =head3 current_holds
784
785 =cut
786
787 sub current_holds {
788     my ( $self ) = @_;
789     my $attributes = { order_by => 'priority' };
790     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
791     my $params = {
792         itemnumber => $self->itemnumber,
793         suspend => 0,
794         -or => [
795             reservedate => { '<=' => $dtf->format_date(dt_from_string) },
796             waitingdate => { '!=' => undef },
797         ],
798     };
799     my $hold_rs = $self->_result->reserves->search( $params, $attributes );
800     return Koha::Holds->_new_from_dbic($hold_rs);
801 }
802
803 =head3 stockrotationitem
804
805   my $sritem = Koha::Item->stockrotationitem;
806
807 Returns the stock rotation item associated with the current item.
808
809 =cut
810
811 sub stockrotationitem {
812     my ( $self ) = @_;
813     my $rs = $self->_result->stockrotationitem;
814     return 0 if !$rs;
815     return Koha::StockRotationItem->_new_from_dbic( $rs );
816 }
817
818 =head3 add_to_rota
819
820   my $item = $item->add_to_rota($rota_id);
821
822 Add this item to the rota identified by $ROTA_ID, which means associating it
823 with the first stage of that rota.  Should this item already be associated
824 with a rota, then we will move it to the new rota.
825
826 =cut
827
828 sub add_to_rota {
829     my ( $self, $rota_id ) = @_;
830     Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
831     return $self;
832 }
833
834 =head3 has_pending_hold
835
836   my $is_pending_hold = $item->has_pending_hold();
837
838 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
839
840 =cut
841
842 sub has_pending_hold {
843     my ( $self ) = @_;
844     my $pending_hold = $self->_result->tmp_holdsqueues;
845     return $pending_hold->count ? 1: 0;
846 }
847
848 =head3 as_marc_field
849
850     my $mss   = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
851     my $field = $item->as_marc_field({ [ mss => $mss ] });
852
853 This method returns a MARC::Field object representing the Koha::Item object
854 with the current mappings configuration.
855
856 =cut
857
858 sub as_marc_field {
859     my ( $self, $params ) = @_;
860
861     my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
862     my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
863
864     my @subfields;
865
866     my @columns = $self->_result->result_source->columns;
867
868     foreach my $item_field ( @columns ) {
869         my $mapping = $mss->{ "items.$item_field"}[0];
870         my $tagfield    = $mapping->{tagfield};
871         my $tagsubfield = $mapping->{tagsubfield};
872         next if !$tagfield; # TODO: Should we raise an exception instead?
873                             # Feels like safe fallback is better
874
875         push @subfields, $tagsubfield => $self->$item_field
876             if defined $self->$item_field and $item_field ne '';
877     }
878
879     my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
880     push( @subfields, @{$unlinked_item_subfields} )
881         if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
882
883     my $field;
884
885     $field = MARC::Field->new(
886         "$item_tag", ' ', ' ', @subfields
887     ) if @subfields;
888
889     return $field;
890 }
891
892 =head3 renewal_branchcode
893
894 Returns the branchcode to be recorded in statistics renewal of the item
895
896 =cut
897
898 sub renewal_branchcode {
899
900     my ($self, $params ) = @_;
901
902     my $interface = C4::Context->interface;
903     my $branchcode;
904     if ( $interface eq 'opac' ){
905         my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
906         if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
907             $branchcode = 'OPACRenew';
908         }
909         elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
910             $branchcode = $self->homebranch;
911         }
912         elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
913             $branchcode = $self->checkout->patron->branchcode;
914         }
915         elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
916             $branchcode = $self->checkout->branchcode;
917         }
918         else {
919             $branchcode = "";
920         }
921     } else {
922         $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
923             ? C4::Context->userenv->{branch} : $params->{branch};
924     }
925     return $branchcode;
926 }
927
928 =head3 cover_images
929
930 Return the cover images associated with this item.
931
932 =cut
933
934 sub cover_images {
935     my ( $self ) = @_;
936
937     my $cover_image_rs = $self->_result->cover_images;
938     return unless $cover_image_rs;
939     return Koha::CoverImages->_new_from_dbic($cover_image_rs);
940 }
941
942 =head3 _set_found_trigger
943
944     $self->_set_found_trigger
945
946 Finds the most recent lost item charge for this item and refunds the patron
947 appropriately, taking into account any payments or writeoffs already applied
948 against the charge.
949
950 Internal function, not exported, called only by Koha::Item->store.
951
952 =cut
953
954 sub _set_found_trigger {
955     my ( $self, $pre_mod_item ) = @_;
956
957     ## If item was lost, it has now been found, reverse any list item charges if necessary.
958     my $no_refund_after_days =
959       C4::Context->preference('NoRefundOnLostReturnedItemsAge');
960     if ($no_refund_after_days) {
961         my $today = dt_from_string();
962         my $lost_age_in_days =
963           dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
964           ->in_units('days');
965
966         return $self unless $lost_age_in_days < $no_refund_after_days;
967     }
968
969     my $lostreturn_policy = Koha::CirculationRules->get_lostreturn_policy(
970         {
971             item          => $self,
972             return_branch => C4::Context->userenv
973             ? C4::Context->userenv->{'branch'}
974             : undef,
975         }
976       );
977
978     if ( $lostreturn_policy ) {
979
980         # refund charge made for lost book
981         my $lost_charge = Koha::Account::Lines->search(
982             {
983                 itemnumber      => $self->itemnumber,
984                 debit_type_code => 'LOST',
985                 status          => [ undef, { '<>' => 'FOUND' } ]
986             },
987             {
988                 order_by => { -desc => [ 'date', 'accountlines_id' ] },
989                 rows     => 1
990             }
991         )->single;
992
993         if ( $lost_charge ) {
994
995             my $patron = $lost_charge->patron;
996             if ( $patron ) {
997
998                 my $account = $patron->account;
999                 my $total_to_refund = 0;
1000
1001                 # Use cases
1002                 if ( $lost_charge->amount > $lost_charge->amountoutstanding ) {
1003
1004                     # some amount has been cancelled. collect the offsets that are not writeoffs
1005                     # this works because the only way to subtract from this kind of a debt is
1006                     # using the UI buttons 'Pay' and 'Write off'
1007                     my $credits_offsets = Koha::Account::Offsets->search(
1008                         {
1009                             debit_id  => $lost_charge->id,
1010                             credit_id => { '!=' => undef },     # it is not the debit itself
1011                             type      => { '!=' => 'Writeoff' },
1012                             amount    => { '<' => 0 }    # credits are negative on the DB
1013                         }
1014                     );
1015
1016                     $total_to_refund = ( $credits_offsets->count > 0 )
1017                       ? $credits_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 =head2 Internal methods
1177
1178 =head3 _after_item_action_hooks
1179
1180 Helper method that takes care of calling all plugin hooks
1181
1182 =cut
1183
1184 sub _after_item_action_hooks {
1185     my ( $self, $params ) = @_;
1186
1187     my $action = $params->{action};
1188
1189     Koha::Plugins->call(
1190         'after_item_action',
1191         {
1192             action  => $action,
1193             item    => $self,
1194             item_id => $self->itemnumber,
1195         }
1196     );
1197 }
1198
1199 =head3 _type
1200
1201 =cut
1202
1203 sub _type {
1204     return 'Item';
1205 }
1206
1207 =head1 AUTHOR
1208
1209 Kyle M Hall <kyle@bywatersolutions.com>
1210
1211 =cut
1212
1213 1;