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