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