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