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