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