Bug 11889: (follow-up) Get rid of FIXME in Koha::Patron
[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 Try::Tiny qw( catch try );
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::BackgroundJob::BatchUpdateBiblioHoldsQueue;
35 use Koha::Biblio::ItemGroups;
36 use Koha::Checkouts;
37 use Koha::CirculationRules;
38 use Koha::CoverImages;
39 use Koha::Exceptions::Item::Transfer;
40 use Koha::Item::Attributes;
41 use Koha::Item::Transfer::Limits;
42 use Koha::Item::Transfers;
43 use Koha::ItemTypes;
44 use Koha::Libraries;
45 use Koha::Patrons;
46 use Koha::Plugins;
47 use Koha::Result::Boolean;
48 use Koha::SearchEngine::Indexer;
49 use Koha::StockRotationItem;
50 use Koha::StockRotationRotas;
51 use Koha::TrackedLinks;
52
53 use base qw(Koha::Object);
54
55 =head1 NAME
56
57 Koha::Item - Koha Item object class
58
59 =head1 API
60
61 =head2 Class methods
62
63 =cut
64
65 =head3 store
66
67     $item->store;
68
69 $params can take an optional 'skip_record_index' parameter.
70 If set, the reindexation process will not happen (index_records not called)
71
72 NOTE: This is a temporary fix to answer a performance issue when lot of items
73 are added (or modified) at the same time.
74 The correct way to fix this is to make the ES reindexation process async.
75 You should not turn it on if you do not understand what it is doing exactly.
76
77 =cut
78
79 sub store {
80     my $self = shift;
81     my $params = @_ ? shift : {};
82
83     my $log_action = $params->{log_action} // 1;
84
85     # We do not want to oblige callers to pass this value
86     # Dev conveniences vs performance?
87     unless ( $self->biblioitemnumber ) {
88         $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
89     }
90
91     # See related changes from C4::Items::AddItem
92     unless ( $self->itype ) {
93         $self->itype($self->biblio->biblioitem->itemtype);
94     }
95
96     $self->barcode( C4::Circulation::barcodedecode( $self->barcode ) );
97
98     my $today  = dt_from_string;
99     my $action = 'create';
100
101     unless ( $self->in_storage ) { #AddItem
102
103         unless ( $self->permanent_location ) {
104             $self->permanent_location($self->location);
105         }
106
107         my $default_location = C4::Context->preference('NewItemsDefaultLocation');
108         unless ( $self->location || !$default_location ) {
109             $self->permanent_location( $self->location || $default_location )
110               unless $self->permanent_location;
111             $self->location($default_location);
112         }
113
114         unless ( $self->replacementpricedate ) {
115             $self->replacementpricedate($today);
116         }
117         unless ( $self->datelastseen ) {
118             $self->datelastseen($today);
119         }
120
121         unless ( $self->dateaccessioned ) {
122             $self->dateaccessioned($today);
123         }
124
125         if (   $self->itemcallnumber
126             or $self->cn_source )
127         {
128             my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
129             $self->cn_sort($cn_sort);
130         }
131
132     } else { # ModItem
133
134         $action = 'modify';
135
136         my %updated_columns = $self->_result->get_dirty_columns;
137         return $self->SUPER::store unless %updated_columns;
138
139         # Retrieve the item for comparison if we need to
140         my $pre_mod_item = (
141                  exists $updated_columns{itemlost}
142               or exists $updated_columns{withdrawn}
143               or exists $updated_columns{damaged}
144         ) ? $self->get_from_storage : undef;
145
146         # Update *_on  fields if needed
147         # FIXME: Why not for AddItem as well?
148         my @fields = qw( itemlost withdrawn damaged );
149         for my $field (@fields) {
150
151             # If the field is defined but empty or 0, we are
152             # removing/unsetting and thus need to clear out
153             # the 'on' field
154             if (   exists $updated_columns{$field}
155                 && defined( $self->$field )
156                 && !$self->$field )
157             {
158                 my $field_on = "${field}_on";
159                 $self->$field_on(undef);
160             }
161             # If the field has changed otherwise, we much update
162             # the 'on' field
163             elsif (exists $updated_columns{$field}
164                 && $updated_columns{$field}
165                 && !$pre_mod_item->$field )
166             {
167                 my $field_on = "${field}_on";
168                 $self->$field_on(
169                     DateTime::Format::MySQL->format_datetime(
170                         dt_from_string()
171                     )
172                 );
173             }
174         }
175
176         if (   exists $updated_columns{itemcallnumber}
177             or exists $updated_columns{cn_source} )
178         {
179             my $cn_sort = GetClassSort( $self->cn_source, $self->itemcallnumber, "" );
180             $self->cn_sort($cn_sort);
181         }
182
183
184         if (    exists $updated_columns{location}
185             and $self->location ne 'CART'
186             and $self->location ne 'PROC'
187             and not exists $updated_columns{permanent_location} )
188         {
189             $self->permanent_location( $self->location );
190         }
191
192         # If item was lost and has now been found,
193         # reverse any list item charges if necessary.
194         if (    exists $updated_columns{itemlost}
195             and $updated_columns{itemlost} <= 0
196             and $pre_mod_item->itemlost > 0 )
197         {
198             $self->_set_found_trigger($pre_mod_item);
199         }
200
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, $self );
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     Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
215         {
216             biblio_ids => [ $self->biblionumber ]
217         }
218     ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
219
220     return $result;
221 }
222
223 =head3 delete
224
225 =cut
226
227 sub delete {
228     my $self = shift;
229     my $params = @_ ? shift : {};
230
231     # FIXME check the item has no current issues
232     # i.e. raise the appropriate exception
233
234     # Get the item group so we can delete it later if it has no items left
235     my $item_group = C4::Context->preference('EnableItemGroups') ? $self->item_group : undef;
236
237     my $result = $self->SUPER::delete;
238
239     # Delete the item gorup if it has no items left
240     $item_group->delete if ( $item_group && $item_group->items->count == 0 );
241
242     my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
243     $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" )
244         unless $params->{skip_record_index};
245
246     $self->_after_item_action_hooks({ action => 'delete' });
247
248     logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
249       if C4::Context->preference("CataloguingLog");
250
251     Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
252         {
253             biblio_ids => [ $self->biblionumber ]
254         }
255     ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
256
257     return $result;
258 }
259
260 =head3 safe_delete
261
262 =cut
263
264 sub safe_delete {
265     my $self = shift;
266     my $params = @_ ? shift : {};
267
268     my $safe_to_delete = $self->safe_to_delete;
269     return $safe_to_delete unless $safe_to_delete;
270
271     $self->move_to_deleted;
272
273     return $self->delete($params);
274 }
275
276 =head3 safe_to_delete
277
278 returns 1 if the item is safe to delete,
279
280 "book_on_loan" if the item is checked out,
281
282 "not_same_branch" if the item is blocked by independent branches,
283
284 "book_reserved" if the there are holds aganst the item, or
285
286 "linked_analytics" if the item has linked analytic records.
287
288 "last_item_for_hold" if the item is the last one on a record on which a biblio-level hold is placed
289
290 =cut
291
292 sub safe_to_delete {
293     my ($self) = @_;
294
295     my $error;
296
297     $error = "book_on_loan" if $self->checkout;
298
299     $error = "not_same_branch"
300       if defined C4::Context->userenv
301       and !C4::Context->IsSuperLibrarian()
302       and C4::Context->preference("IndependentBranches")
303       and ( C4::Context->userenv->{branch} ne $self->homebranch );
304
305     # check it doesn't have a waiting reserve
306     $error = "book_reserved"
307       if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
308
309     $error = "linked_analytics"
310       if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
311
312     $error = "last_item_for_hold"
313       if $self->biblio->items->count == 1
314       && $self->biblio->holds->search(
315           {
316               itemnumber => undef,
317           }
318         )->count;
319
320     if ( $error ) {
321         return Koha::Result::Boolean->new(0)->add_message({ message => $error });
322     }
323
324     return Koha::Result::Boolean->new(1);
325 }
326
327 =head3 move_to_deleted
328
329 my $is_moved = $item->move_to_deleted;
330
331 Move an item to the deleteditems table.
332 This can be done before deleting an item, to make sure the data are not completely deleted.
333
334 =cut
335
336 sub move_to_deleted {
337     my ($self) = @_;
338     my $item_infos = $self->unblessed;
339     delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
340     return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
341 }
342
343
344 =head3 effective_itemtype
345
346 Returns the itemtype for the item based on whether item level itemtypes are set or not.
347
348 =cut
349
350 sub effective_itemtype {
351     my ( $self ) = @_;
352
353     return $self->_result()->effective_itemtype();
354 }
355
356 =head3 home_branch
357
358 =cut
359
360 sub home_branch {
361     my ($self) = @_;
362
363     $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
364
365     return $self->{_home_branch};
366 }
367
368 =head3 holding_branch
369
370 =cut
371
372 sub holding_branch {
373     my ($self) = @_;
374
375     $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
376
377     return $self->{_holding_branch};
378 }
379
380 =head3 biblio
381
382 my $biblio = $item->biblio;
383
384 Return the bibliographic record of this item
385
386 =cut
387
388 sub biblio {
389     my ( $self ) = @_;
390     my $biblio_rs = $self->_result->biblio;
391     return Koha::Biblio->_new_from_dbic( $biblio_rs );
392 }
393
394 =head3 biblioitem
395
396 my $biblioitem = $item->biblioitem;
397
398 Return the biblioitem record of this item
399
400 =cut
401
402 sub biblioitem {
403     my ( $self ) = @_;
404     my $biblioitem_rs = $self->_result->biblioitem;
405     return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
406 }
407
408 =head3 checkout
409
410 my $checkout = $item->checkout;
411
412 Return the checkout for this item
413
414 =cut
415
416 sub checkout {
417     my ( $self ) = @_;
418     my $checkout_rs = $self->_result->issue;
419     return unless $checkout_rs;
420     return Koha::Checkout->_new_from_dbic( $checkout_rs );
421 }
422
423 =head3 item_group
424
425 my $item_group = $item->item_group;
426
427 Return the item group for this item
428
429 =cut
430
431 sub item_group {
432     my ( $self ) = @_;
433
434     my $item_group_item = $self->_result->item_group_item;
435     return unless $item_group_item;
436
437     my $item_group_rs = $item_group_item->item_group;
438     return unless $item_group_rs;
439
440     my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs );
441     return $item_group;
442 }
443
444 =head3 return_claims
445
446   my $return_claims = $item->return_claims;
447
448 Return any return_claims associated with this item
449
450 =cut
451
452 sub return_claims {
453     my ( $self, $params, $attrs ) = @_;
454     my $claims_rs = $self->_result->return_claims->search($params, $attrs);
455     return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs );
456 }
457
458 =head3 return_claim
459
460   my $return_claim = $item->return_claim;
461
462 Returns the most recent unresolved return_claims associated with this item
463
464 =cut
465
466 sub return_claim {
467     my ($self) = @_;
468     my $claims_rs =
469       $self->_result->return_claims->search( { resolution => undef },
470         { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single;
471     return unless $claims_rs;
472     return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs);
473 }
474
475 =head3 holds
476
477 my $holds = $item->holds();
478 my $holds = $item->holds($params);
479 my $holds = $item->holds({ found => 'W'});
480
481 Return holds attached to an item, optionally accept a hashref of params to pass to search
482
483 =cut
484
485 sub holds {
486     my ( $self,$params ) = @_;
487     my $holds_rs = $self->_result->reserves->search($params);
488     return Koha::Holds->_new_from_dbic( $holds_rs );
489 }
490
491 =head3 request_transfer
492
493   my $transfer = $item->request_transfer(
494     {
495         to     => $to_library,
496         reason => $reason,
497         [ ignore_limits => 0, enqueue => 1, replace => 1 ]
498     }
499   );
500
501 Add a transfer request for this item to the given branch for the given reason.
502
503 An exception will be thrown if the BranchTransferLimits would prevent the requested
504 transfer, unless 'ignore_limits' is passed to override the limits.
505
506 An exception will be thrown if an active transfer (i.e pending arrival date) is found;
507 The caller should catch such cases and retry the transfer request as appropriate passing
508 an appropriate override.
509
510 Overrides
511 * enqueue - Used to queue up the transfer when the existing transfer is found to be in transit.
512 * replace - Used to replace the existing transfer request with your own.
513
514 =cut
515
516 sub request_transfer {
517     my ( $self, $params ) = @_;
518
519     # check for mandatory params
520     my @mandatory = ( 'to', 'reason' );
521     for my $param (@mandatory) {
522         unless ( defined( $params->{$param} ) ) {
523             Koha::Exceptions::MissingParameter->throw(
524                 error => "The $param parameter is mandatory" );
525         }
526     }
527
528     Koha::Exceptions::Item::Transfer::Limit->throw()
529       unless ( $params->{ignore_limits}
530         || $self->can_be_transferred( { to => $params->{to} } ) );
531
532     my $request = $self->get_transfer;
533     Koha::Exceptions::Item::Transfer::InQueue->throw( transfer => $request )
534       if ( $request && !$params->{enqueue} && !$params->{replace} );
535
536     $request->cancel( { reason => $params->{reason}, force => 1 } )
537       if ( defined($request) && $params->{replace} );
538
539     my $transfer = Koha::Item::Transfer->new(
540         {
541             itemnumber    => $self->itemnumber,
542             daterequested => dt_from_string,
543             frombranch    => $self->holdingbranch,
544             tobranch      => $params->{to}->branchcode,
545             reason        => $params->{reason},
546             comments      => $params->{comment}
547         }
548     )->store();
549
550     return $transfer;
551 }
552
553 =head3 get_transfer
554
555   my $transfer = $item->get_transfer;
556
557 Return the active transfer request or undef
558
559 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
560 whereby the most recently sent, but not received, transfer will be returned
561 if it exists, otherwise the oldest unsatisfied transfer will be returned.
562
563 This allows for transfers to queue, which is the case for stock rotation and
564 rotating collections where a manual transfer may need to take precedence but
565 we still expect the item to end up at a final location eventually.
566
567 =cut
568
569 sub get_transfer {
570     my ($self) = @_;
571     my $transfer_rs = $self->_result->branchtransfers->search(
572         {
573             datearrived   => undef,
574             datecancelled => undef
575         },
576         {
577             order_by =>
578               [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
579             rows => 1
580         }
581     )->first;
582     return unless $transfer_rs;
583     return Koha::Item::Transfer->_new_from_dbic($transfer_rs);
584 }
585
586 =head3 get_transfers
587
588   my $transfer = $item->get_transfers;
589
590 Return the list of outstanding transfers (i.e requested but not yet cancelled
591 or received).
592
593 Note: Transfers are retrieved in a Modified FIFO (First In First Out) order
594 whereby the most recently sent, but not received, transfer will be returned
595 first if it exists, otherwise requests are in oldest to newest request order.
596
597 This allows for transfers to queue, which is the case for stock rotation and
598 rotating collections where a manual transfer may need to take precedence but
599 we still expect the item to end up at a final location eventually.
600
601 =cut
602
603 sub get_transfers {
604     my ($self) = @_;
605     my $transfer_rs = $self->_result->branchtransfers->search(
606         {
607             datearrived   => undef,
608             datecancelled => undef
609         },
610         {
611             order_by =>
612               [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
613         }
614     );
615     return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
616 }
617
618 =head3 last_returned_by
619
620 Gets and sets the last borrower to return an item.
621
622 Accepts and returns Koha::Patron objects
623
624 $item->last_returned_by( $borrowernumber );
625
626 $last_returned_by = $item->last_returned_by();
627
628 =cut
629
630 sub last_returned_by {
631     my ( $self, $borrower ) = @_;
632
633     my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
634
635     if ($borrower) {
636         return $items_last_returned_by_rs->update_or_create(
637             { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
638     }
639     else {
640         unless ( $self->{_last_returned_by} ) {
641             my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
642             if ($result) {
643                 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
644             }
645         }
646
647         return $self->{_last_returned_by};
648     }
649 }
650
651 =head3 can_article_request
652
653 my $bool = $item->can_article_request( $borrower )
654
655 Returns true if item can be specifically requested
656
657 $borrower must be a Koha::Patron object
658
659 =cut
660
661 sub can_article_request {
662     my ( $self, $borrower ) = @_;
663
664     my $rule = $self->article_request_type($borrower);
665
666     return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
667     return q{};
668 }
669
670 =head3 hidden_in_opac
671
672 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
673
674 Returns true if item fields match the hidding criteria defined in $rules.
675 Returns false otherwise.
676
677 Takes HASHref that can have the following parameters:
678     OPTIONAL PARAMETERS:
679     $rules : { <field> => [ value_1, ... ], ... }
680
681 Note: $rules inherits its structure from the parsed YAML from reading
682 the I<OpacHiddenItems> system preference.
683
684 =cut
685
686 sub hidden_in_opac {
687     my ( $self, $params ) = @_;
688
689     my $rules = $params->{rules} // {};
690
691     return 1
692         if C4::Context->preference('hidelostitems') and
693            $self->itemlost > 0;
694
695     my $hidden_in_opac = 0;
696
697     foreach my $field ( keys %{$rules} ) {
698
699         if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
700             $hidden_in_opac = 1;
701             last;
702         }
703     }
704
705     return $hidden_in_opac;
706 }
707
708 =head3 can_be_transferred
709
710 $item->can_be_transferred({ to => $to_library, from => $from_library })
711 Checks if an item can be transferred to given library.
712
713 This feature is controlled by two system preferences:
714 UseBranchTransferLimits to enable / disable the feature
715 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
716                          for setting the limitations
717
718 Takes HASHref that can have the following parameters:
719     MANDATORY PARAMETERS:
720     $to   : Koha::Library
721     OPTIONAL PARAMETERS:
722     $from : Koha::Library  # if not given, item holdingbranch
723                            # will be used instead
724
725 Returns 1 if item can be transferred to $to_library, otherwise 0.
726
727 To find out whether at least one item of a Koha::Biblio can be transferred, please
728 see Koha::Biblio->can_be_transferred() instead of using this method for
729 multiple items of the same biblio.
730
731 =cut
732
733 sub can_be_transferred {
734     my ($self, $params) = @_;
735
736     my $to   = $params->{to};
737     my $from = $params->{from};
738
739     $to   = $to->branchcode;
740     $from = defined $from ? $from->branchcode : $self->holdingbranch;
741
742     return 1 if $from eq $to; # Transfer to current branch is allowed
743     return 1 unless C4::Context->preference('UseBranchTransferLimits');
744
745     my $limittype = C4::Context->preference('BranchTransferLimitsType');
746     return Koha::Item::Transfer::Limits->search({
747         toBranch => $to,
748         fromBranch => $from,
749         $limittype => $limittype eq 'itemtype'
750                         ? $self->effective_itemtype : $self->ccode
751     })->count ? 0 : 1;
752
753 }
754
755 =head3 pickup_locations
756
757 $pickup_locations = $item->pickup_locations( {patron => $patron } )
758
759 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)
760 and if item can be transferred to each pickup location.
761
762 =cut
763
764 sub pickup_locations {
765     my ($self, $params) = @_;
766
767     my $patron = $params->{patron};
768
769     my $circ_control_branch =
770       C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
771     my $branchitemrule =
772       C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
773
774     if(defined $patron) {
775         return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_local_hold_group' && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
776         return Koha::Libraries->new()->empty if $branchitemrule->{holdallowed} eq 'from_home_library' && $self->home_branch->branchcode ne $patron->branchcode;
777     }
778
779     my $pickup_libraries = Koha::Libraries->search();
780     if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
781         $pickup_libraries = $self->home_branch->get_hold_libraries;
782     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
783         my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
784         $pickup_libraries = $plib->get_hold_libraries;
785     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
786         $pickup_libraries = Koha::Libraries->search({ branchcode => $self->homebranch });
787     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
788         $pickup_libraries = Koha::Libraries->search({ branchcode => $self->holdingbranch });
789     };
790
791     return $pickup_libraries->search(
792         {
793             pickup_location => 1
794         },
795         {
796             order_by => ['branchname']
797         }
798     ) unless C4::Context->preference('UseBranchTransferLimits');
799
800     my $limittype = C4::Context->preference('BranchTransferLimitsType');
801     my ($ccode, $itype) = (undef, undef);
802     if( $limittype eq 'ccode' ){
803         $ccode = $self->ccode;
804     } else {
805         $itype = $self->itype;
806     }
807     my $limits = Koha::Item::Transfer::Limits->search(
808         {
809             fromBranch => $self->holdingbranch,
810             ccode      => $ccode,
811             itemtype   => $itype,
812         },
813         { columns => ['toBranch'] }
814     );
815
816     return $pickup_libraries->search(
817         {
818             pickup_location => 1,
819             branchcode      => {
820                 '-not_in' => $limits->_resultset->as_query
821             }
822         },
823         {
824             order_by => ['branchname']
825         }
826     );
827 }
828
829 =head3 article_request_type
830
831 my $type = $item->article_request_type( $borrower )
832
833 returns 'yes', 'no', 'bib_only', or 'item_only'
834
835 $borrower must be a Koha::Patron object
836
837 =cut
838
839 sub article_request_type {
840     my ( $self, $borrower ) = @_;
841
842     my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
843     my $branchcode =
844         $branch_control eq 'homebranch'    ? $self->homebranch
845       : $branch_control eq 'holdingbranch' ? $self->holdingbranch
846       :                                      undef;
847     my $borrowertype = $borrower->categorycode;
848     my $itemtype = $self->effective_itemtype();
849     my $rule = Koha::CirculationRules->get_effective_rule(
850         {
851             rule_name    => 'article_requests',
852             categorycode => $borrowertype,
853             itemtype     => $itemtype,
854             branchcode   => $branchcode
855         }
856     );
857
858     return q{} unless $rule;
859     return $rule->rule_value || q{}
860 }
861
862 =head3 current_holds
863
864 =cut
865
866 sub current_holds {
867     my ( $self ) = @_;
868     my $attributes = { order_by => 'priority' };
869     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
870     my $params = {
871         itemnumber => $self->itemnumber,
872         suspend => 0,
873         -or => [
874             reservedate => { '<=' => $dtf->format_date(dt_from_string) },
875             waitingdate => { '!=' => undef },
876         ],
877     };
878     my $hold_rs = $self->_result->reserves->search( $params, $attributes );
879     return Koha::Holds->_new_from_dbic($hold_rs);
880 }
881
882 =head3 stockrotationitem
883
884   my $sritem = Koha::Item->stockrotationitem;
885
886 Returns the stock rotation item associated with the current item.
887
888 =cut
889
890 sub stockrotationitem {
891     my ( $self ) = @_;
892     my $rs = $self->_result->stockrotationitem;
893     return 0 if !$rs;
894     return Koha::StockRotationItem->_new_from_dbic( $rs );
895 }
896
897 =head3 add_to_rota
898
899   my $item = $item->add_to_rota($rota_id);
900
901 Add this item to the rota identified by $ROTA_ID, which means associating it
902 with the first stage of that rota.  Should this item already be associated
903 with a rota, then we will move it to the new rota.
904
905 =cut
906
907 sub add_to_rota {
908     my ( $self, $rota_id ) = @_;
909     Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
910     return $self;
911 }
912
913 =head3 has_pending_hold
914
915   my $is_pending_hold = $item->has_pending_hold();
916
917 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
918
919 =cut
920
921 sub has_pending_hold {
922     my ( $self ) = @_;
923     my $pending_hold = $self->_result->tmp_holdsqueues;
924     return $pending_hold->count ? 1: 0;
925 }
926
927 =head3 as_marc_field
928
929     my $field = $item->as_marc_field;
930
931 This method returns a MARC::Field object representing the Koha::Item object
932 with the current mappings configuration.
933
934 =cut
935
936 sub as_marc_field {
937     my ( $self ) = @_;
938
939     my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
940
941     my $tagslib = C4::Biblio::GetMarcStructure( 1, $self->biblio->frameworkcode, { unsafe => 1 });
942
943     my @subfields;
944
945     my $item_field = $tagslib->{$itemtag};
946
947     my $more_subfields = $self->additional_attributes->to_hashref;
948     foreach my $subfield (
949         sort {
950                $a->{display_order} <=> $b->{display_order}
951             || $a->{subfield} cmp $b->{subfield}
952         } grep { ref($_) && %$_ } values %$item_field
953     ){
954
955         my $kohafield = $subfield->{kohafield};
956         my $tagsubfield = $subfield->{tagsubfield};
957         my $value;
958         if ( defined $kohafield ) {
959             next if $kohafield !~ m{^items\.}; # That would be weird!
960             ( my $attribute = $kohafield ) =~ s|^items\.||;
961             $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
962                 if defined $self->$attribute and $self->$attribute ne '';
963         } else {
964             $value = $more_subfields->{$tagsubfield}
965         }
966
967         next unless defined $value
968             and $value ne q{};
969
970         if ( $subfield->{repeatable} ) {
971             my @values = split '\|', $value;
972             push @subfields, ( $tagsubfield => $_ ) for @values;
973         }
974         else {
975             push @subfields, ( $tagsubfield => $value );
976         }
977
978     }
979
980     return unless @subfields;
981
982     return MARC::Field->new(
983         "$itemtag", ' ', ' ', @subfields
984     );
985 }
986
987 =head3 renewal_branchcode
988
989 Returns the branchcode to be recorded in statistics renewal of the item
990
991 =cut
992
993 sub renewal_branchcode {
994
995     my ($self, $params ) = @_;
996
997     my $interface = C4::Context->interface;
998     my $branchcode;
999     if ( $interface eq 'opac' ){
1000         my $renewal_branchcode = C4::Context->preference('OpacRenewalBranch');
1001         if( !defined $renewal_branchcode || $renewal_branchcode eq 'opacrenew' ){
1002             $branchcode = 'OPACRenew';
1003         }
1004         elsif ( $renewal_branchcode eq 'itemhomebranch' ) {
1005             $branchcode = $self->homebranch;
1006         }
1007         elsif ( $renewal_branchcode eq 'patronhomebranch' ) {
1008             $branchcode = $self->checkout->patron->branchcode;
1009         }
1010         elsif ( $renewal_branchcode eq 'checkoutbranch' ) {
1011             $branchcode = $self->checkout->branchcode;
1012         }
1013         else {
1014             $branchcode = "";
1015         }
1016     } else {
1017         $branchcode = ( C4::Context->userenv && defined C4::Context->userenv->{branch} )
1018             ? C4::Context->userenv->{branch} : $params->{branch};
1019     }
1020     return $branchcode;
1021 }
1022
1023 =head3 cover_images
1024
1025 Return the cover images associated with this item.
1026
1027 =cut
1028
1029 sub cover_images {
1030     my ( $self ) = @_;
1031
1032     my $cover_image_rs = $self->_result->cover_images;
1033     return unless $cover_image_rs;
1034     return Koha::CoverImages->_new_from_dbic($cover_image_rs);
1035 }
1036
1037 =head3 columns_to_str
1038
1039     my $values = $items->columns_to_str;
1040
1041 Return a hashref with the string representation of the different attribute of the item.
1042
1043 This is meant to be used for display purpose only.
1044
1045 =cut
1046
1047 sub columns_to_str {
1048     my ( $self ) = @_;
1049
1050     my $frameworkcode = $self->biblio->frameworkcode;
1051     my $tagslib = C4::Biblio::GetMarcStructure(1, $frameworkcode);
1052     my ( $itemtagfield, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
1053
1054     my $columns_info = $self->_result->result_source->columns_info;
1055
1056     my $mss = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
1057     my $values = {};
1058     for my $column ( keys %$columns_info ) {
1059
1060         next if $column eq 'more_subfields_xml';
1061
1062         my $value = $self->$column;
1063         # 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
1064
1065         if ( not defined $value or $value eq "" ) {
1066             $values->{$column} = $value;
1067             next;
1068         }
1069
1070         my $subfield =
1071           exists $mss->{"items.$column"}
1072           ? @{ $mss->{"items.$column"} }[0] # Should we deal with several subfields??
1073           : undef;
1074
1075         $values->{$column} =
1076             $subfield
1077           ? $subfield->{authorised_value}
1078               ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1079                   $subfield->{tagsubfield}, $value, '', $tagslib )
1080               : $value
1081           : $value;
1082     }
1083
1084     my $marc_more=
1085       $self->more_subfields_xml
1086       ? MARC::Record->new_from_xml( $self->more_subfields_xml, 'UTF-8' )
1087       : undef;
1088
1089     my $more_values;
1090     if ( $marc_more ) {
1091         my ( $field ) = $marc_more->fields;
1092         for my $sf ( $field->subfields ) {
1093             my $subfield_code = $sf->[0];
1094             my $value = $sf->[1];
1095             my $subfield = $tagslib->{$itemtagfield}->{$subfield_code};
1096             next unless $subfield; # We have the value but it's not mapped, data lose! No regression however.
1097             $value =
1098               $subfield->{authorised_value}
1099               ? C4::Biblio::GetAuthorisedValueDesc( $itemtagfield,
1100                 $subfield->{tagsubfield}, $value, '', $tagslib )
1101               : $value;
1102
1103             push @{$more_values->{$subfield_code}}, $value;
1104         }
1105
1106         while ( my ( $k, $v ) = each %$more_values ) {
1107             $values->{$k} = join ' | ', @$v;
1108         }
1109     }
1110
1111     return $values;
1112 }
1113
1114 =head3 additional_attributes
1115
1116     my $attributes = $item->additional_attributes;
1117     $attributes->{k} = 'new k';
1118     $item->update({ more_subfields => $attributes->to_marcxml });
1119
1120 Returns a Koha::Item::Attributes object that represents the non-mapped
1121 attributes for this item.
1122
1123 =cut
1124
1125 sub additional_attributes {
1126     my ($self) = @_;
1127
1128     return Koha::Item::Attributes->new_from_marcxml(
1129         $self->more_subfields_xml,
1130     );
1131 }
1132
1133 =head3 _set_found_trigger
1134
1135     $self->_set_found_trigger
1136
1137 Finds the most recent lost item charge for this item and refunds the patron
1138 appropriately, taking into account any payments or writeoffs already applied
1139 against the charge.
1140
1141 Internal function, not exported, called only by Koha::Item->store.
1142
1143 =cut
1144
1145 sub _set_found_trigger {
1146     my ( $self, $pre_mod_item ) = @_;
1147
1148     # Reverse any lost item charges if necessary.
1149     my $no_refund_after_days =
1150       C4::Context->preference('NoRefundOnLostReturnedItemsAge');
1151     if ($no_refund_after_days) {
1152         my $today = dt_from_string();
1153         my $lost_age_in_days =
1154           dt_from_string( $pre_mod_item->itemlost_on )->delta_days($today)
1155           ->in_units('days');
1156
1157         return $self unless $lost_age_in_days < $no_refund_after_days;
1158     }
1159
1160     my $lostreturn_policy = Koha::CirculationRules->get_lostreturn_policy(
1161         {
1162             item          => $self,
1163             return_branch => C4::Context->userenv
1164             ? C4::Context->userenv->{'branch'}
1165             : undef,
1166         }
1167       );
1168
1169     if ( $lostreturn_policy ) {
1170
1171         # refund charge made for lost book
1172         my $lost_charge = Koha::Account::Lines->search(
1173             {
1174                 itemnumber      => $self->itemnumber,
1175                 debit_type_code => 'LOST',
1176                 status          => [ undef, { '<>' => 'FOUND' } ]
1177             },
1178             {
1179                 order_by => { -desc => [ 'date', 'accountlines_id' ] },
1180                 rows     => 1
1181             }
1182         )->single;
1183
1184         if ( $lost_charge ) {
1185
1186             my $patron = $lost_charge->patron;
1187             if ( $patron ) {
1188
1189                 my $account = $patron->account;
1190                 my $total_to_refund = 0;
1191
1192                 # Use cases
1193                 if ( $lost_charge->amount > $lost_charge->amountoutstanding ) {
1194
1195                     # some amount has been cancelled. collect the offsets that are not writeoffs
1196                     # this works because the only way to subtract from this kind of a debt is
1197                     # using the UI buttons 'Pay' and 'Write off'
1198                     my $credit_offsets = $lost_charge->debit_offsets(
1199                         {
1200                             'credit_id'               => { '!=' => undef },
1201                             'credit.credit_type_code' => { '!=' => 'Writeoff' }
1202                         },
1203                         { join => 'credit' }
1204                     );
1205
1206                     $total_to_refund = ( $credit_offsets->count > 0 )
1207                       ? $credit_offsets->total * -1    # credits are negative on the DB
1208                       : 0;
1209                 }
1210
1211                 my $credit_total = $lost_charge->amountoutstanding + $total_to_refund;
1212
1213                 my $credit;
1214                 if ( $credit_total > 0 ) {
1215                     my $branchcode =
1216                       C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1217                     $credit = $account->add_credit(
1218                         {
1219                             amount      => $credit_total,
1220                             description => 'Item found ' . $self->itemnumber,
1221                             type        => 'LOST_FOUND',
1222                             interface   => C4::Context->interface,
1223                             library_id  => $branchcode,
1224                             item_id     => $self->itemnumber,
1225                             issue_id    => $lost_charge->issue_id
1226                         }
1227                     );
1228
1229                     $credit->apply( { debits => [$lost_charge] } );
1230                     $self->add_message(
1231                         {
1232                             type    => 'info',
1233                             message => 'lost_refunded',
1234                             payload => { credit_id => $credit->id }
1235                         }
1236                     );
1237                 }
1238
1239                 # Update the account status
1240                 $lost_charge->status('FOUND');
1241                 $lost_charge->store();
1242
1243                 # Reconcile balances if required
1244                 if ( C4::Context->preference('AccountAutoReconcile') ) {
1245                     $account->reconcile_balance;
1246                 }
1247             }
1248         }
1249
1250         # restore fine for lost book
1251         if ( $lostreturn_policy eq 'restore' ) {
1252             my $lost_overdue = Koha::Account::Lines->search(
1253                 {
1254                     itemnumber      => $self->itemnumber,
1255                     debit_type_code => 'OVERDUE',
1256                     status          => 'LOST'
1257                 },
1258                 {
1259                     order_by => { '-desc' => 'date' },
1260                     rows     => 1
1261                 }
1262             )->single;
1263
1264             if ( $lost_overdue ) {
1265
1266                 my $patron = $lost_overdue->patron;
1267                 if ($patron) {
1268                     my $account = $patron->account;
1269
1270                     # Update status of fine
1271                     $lost_overdue->status('FOUND')->store();
1272
1273                     # Find related forgive credit
1274                     my $refund = $lost_overdue->credits(
1275                         {
1276                             credit_type_code => 'FORGIVEN',
1277                             itemnumber       => $self->itemnumber,
1278                             status           => [ { '!=' => 'VOID' }, undef ]
1279                         },
1280                         { order_by => { '-desc' => 'date' }, rows => 1 }
1281                     )->single;
1282
1283                     if ( $refund ) {
1284                         # Revert the forgive credit
1285                         $refund->void({ interface => 'trigger' });
1286                         $self->add_message(
1287                             {
1288                                 type    => 'info',
1289                                 message => 'lost_restored',
1290                                 payload => { refund_id => $refund->id }
1291                             }
1292                         );
1293                     }
1294
1295                     # Reconcile balances if required
1296                     if ( C4::Context->preference('AccountAutoReconcile') ) {
1297                         $account->reconcile_balance;
1298                     }
1299                 }
1300             }
1301         } elsif ( $lostreturn_policy eq 'charge' ) {
1302             $self->add_message(
1303                 {
1304                     type    => 'info',
1305                     message => 'lost_charge',
1306                 }
1307             );
1308         }
1309     }
1310
1311     return $self;
1312 }
1313
1314 =head3 public_read_list
1315
1316 This method returns the list of publicly readable database fields for both API and UI output purposes
1317
1318 =cut
1319
1320 sub public_read_list {
1321     return [
1322         'itemnumber',     'biblionumber',    'homebranch',
1323         'holdingbranch',  'location',        'collectioncode',
1324         'itemcallnumber', 'copynumber',      'enumchron',
1325         'barcode',        'dateaccessioned', 'itemnotes',
1326         'onloan',         'uri',             'itype',
1327         'notforloan',     'damaged',         'itemlost',
1328         'withdrawn',      'restricted'
1329     ];
1330 }
1331
1332 =head3 to_api
1333
1334 Overloaded to_api method to ensure item-level itypes is adhered to.
1335
1336 =cut
1337
1338 sub to_api {
1339     my ($self, $params) = @_;
1340
1341     my $response = $self->SUPER::to_api($params);
1342     my $overrides = {};
1343
1344     $overrides->{effective_item_type_id} = $self->effective_itemtype;
1345
1346     return { %$response, %$overrides };
1347 }
1348
1349 =head3 to_api_mapping
1350
1351 This method returns the mapping for representing a Koha::Item object
1352 on the API.
1353
1354 =cut
1355
1356 sub to_api_mapping {
1357     return {
1358         itemnumber               => 'item_id',
1359         biblionumber             => 'biblio_id',
1360         biblioitemnumber         => undef,
1361         barcode                  => 'external_id',
1362         dateaccessioned          => 'acquisition_date',
1363         booksellerid             => 'acquisition_source',
1364         homebranch               => 'home_library_id',
1365         price                    => 'purchase_price',
1366         replacementprice         => 'replacement_price',
1367         replacementpricedate     => 'replacement_price_date',
1368         datelastborrowed         => 'last_checkout_date',
1369         datelastseen             => 'last_seen_date',
1370         stack                    => undef,
1371         notforloan               => 'not_for_loan_status',
1372         damaged                  => 'damaged_status',
1373         damaged_on               => 'damaged_date',
1374         itemlost                 => 'lost_status',
1375         itemlost_on              => 'lost_date',
1376         withdrawn                => 'withdrawn',
1377         withdrawn_on             => 'withdrawn_date',
1378         itemcallnumber           => 'callnumber',
1379         coded_location_qualifier => 'coded_location_qualifier',
1380         issues                   => 'checkouts_count',
1381         renewals                 => 'renewals_count',
1382         reserves                 => 'holds_count',
1383         restricted               => 'restricted_status',
1384         itemnotes                => 'public_notes',
1385         itemnotes_nonpublic      => 'internal_notes',
1386         holdingbranch            => 'holding_library_id',
1387         timestamp                => 'timestamp',
1388         location                 => 'location',
1389         permanent_location       => 'permanent_location',
1390         onloan                   => 'checked_out_date',
1391         cn_source                => 'call_number_source',
1392         cn_sort                  => 'call_number_sort',
1393         ccode                    => 'collection_code',
1394         materials                => 'materials_notes',
1395         uri                      => 'uri',
1396         itype                    => 'item_type_id',
1397         more_subfields_xml       => 'extended_subfields',
1398         enumchron                => 'serial_issue_number',
1399         copynumber               => 'copy_number',
1400         stocknumber              => 'inventory_number',
1401         new_status               => 'new_status'
1402     };
1403 }
1404
1405 =head3 itemtype
1406
1407     my $itemtype = $item->itemtype;
1408
1409     Returns Koha object for effective itemtype
1410
1411 =cut
1412
1413 sub itemtype {
1414     my ( $self ) = @_;
1415     return Koha::ItemTypes->find( $self->effective_itemtype );
1416 }
1417
1418 =head3 orders
1419
1420   my $orders = $item->orders();
1421
1422 Returns a Koha::Acquisition::Orders object
1423
1424 =cut
1425
1426 sub orders {
1427     my ( $self ) = @_;
1428
1429     my $orders = $self->_result->item_orders;
1430     return Koha::Acquisition::Orders->_new_from_dbic($orders);
1431 }
1432
1433 =head3 tracked_links
1434
1435   my $tracked_links = $item->tracked_links();
1436
1437 Returns a Koha::TrackedLinks object
1438
1439 =cut
1440
1441 sub tracked_links {
1442     my ( $self ) = @_;
1443
1444     my $tracked_links = $self->_result->linktrackers;
1445     return Koha::TrackedLinks->_new_from_dbic($tracked_links);
1446 }
1447
1448 =head3 move_to_biblio
1449
1450   $item->move_to_biblio($to_biblio[, $params]);
1451
1452 Move the item to another biblio and update any references in other tables.
1453
1454 The final optional parameter, C<$params>, is expected to contain the
1455 'skip_record_index' key, which is relayed down to Koha::Item->store.
1456 There it prevents calling index_records, which takes most of the
1457 time in batch adds/deletes. The caller must take care of calling
1458 index_records separately.
1459
1460 $params:
1461     skip_record_index => 1|0
1462
1463 Returns undef if the move failed or the biblionumber of the destination record otherwise
1464
1465 =cut
1466
1467 sub move_to_biblio {
1468     my ( $self, $to_biblio, $params ) = @_;
1469
1470     $params //= {};
1471
1472     return if $self->biblionumber == $to_biblio->biblionumber;
1473
1474     my $from_biblionumber = $self->biblionumber;
1475     my $to_biblionumber = $to_biblio->biblionumber;
1476
1477     # Own biblionumber and biblioitemnumber
1478     $self->set({
1479         biblionumber => $to_biblionumber,
1480         biblioitemnumber => $to_biblio->biblioitem->biblioitemnumber
1481     })->store({ skip_record_index => $params->{skip_record_index} });
1482
1483     unless ($params->{skip_record_index}) {
1484         my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1485         $indexer->index_records( $from_biblionumber, "specialUpdate", "biblioserver" );
1486     }
1487
1488     # Acquisition orders
1489     $self->orders->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1490
1491     # Holds
1492     $self->holds->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1493
1494     # hold_fill_target (there's no Koha object available yet)
1495     my $hold_fill_target = $self->_result->hold_fill_target;
1496     if ($hold_fill_target) {
1497         $hold_fill_target->update({ biblionumber => $to_biblionumber });
1498     }
1499
1500     # tmp_holdsqueues - Can't update with DBIx since the table is missing a primary key
1501     # and can't even fake one since the significant columns are nullable.
1502     my $storage = $self->_result->result_source->storage;
1503     $storage->dbh_do(
1504         sub {
1505             my ($storage, $dbh, @cols) = @_;
1506
1507             $dbh->do("UPDATE tmp_holdsqueue SET biblionumber=? WHERE itemnumber=?", undef, $to_biblionumber, $self->itemnumber);
1508         }
1509     );
1510
1511     # tracked_links
1512     $self->tracked_links->update({ biblionumber => $to_biblionumber }, { no_triggers => 1 });
1513
1514     return $to_biblionumber;
1515 }
1516
1517 =head3 bundle_items
1518
1519   my $bundle_items = $item->bundle_items;
1520
1521 Returns the items associated with this bundle
1522
1523 =cut
1524
1525 sub bundle_items {
1526     my ($self) = @_;
1527
1528     if ( !$self->{_bundle_items_cached} ) {
1529         my $bundle_items = Koha::Items->search(
1530             { 'item_bundles_item.host' => $self->itemnumber },
1531             { join                     => 'item_bundles_item' } );
1532         $self->{_bundle_items}        = $bundle_items;
1533         $self->{_bundle_items_cached} = 1;
1534     }
1535
1536     return $self->{_bundle_items};
1537 }
1538
1539 =head3 is_bundle
1540
1541   my $is_bundle = $item->is_bundle;
1542
1543 Returns whether the item is a bundle or not
1544
1545 =cut
1546
1547 sub is_bundle {
1548     my ($self) = @_;
1549     return $self->bundle_items->count ? 1 : 0;
1550 }
1551
1552 =head3 bundle_host
1553
1554   my $bundle = $item->bundle_host;
1555
1556 Returns the bundle item this item is attached to
1557
1558 =cut
1559
1560 sub bundle_host {
1561     my ($self) = @_;
1562
1563     my $bundle_items_rs = $self->_result->item_bundles_item;
1564     return unless $bundle_items_rs;
1565     return Koha::Item->_new_from_dbic($bundle_items_rs->host);
1566 }
1567
1568 =head3 in_bundle
1569
1570   my $in_bundle = $item->in_bundle;
1571
1572 Returns whether this item is currently in a bundle
1573
1574 =cut
1575
1576 sub in_bundle {
1577     my ($self) = @_;
1578     return $self->bundle_host ? 1 : 0;
1579 }
1580
1581 =head3 add_to_bundle
1582
1583   my $link = $item->add_to_bundle($bundle_item);
1584
1585 Adds the bundle_item passed to this item
1586
1587 =cut
1588
1589 sub add_to_bundle {
1590     my ( $self, $bundle_item ) = @_;
1591
1592     my $schema = Koha::Database->new->schema;
1593
1594     my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
1595
1596     try {
1597         $schema->txn_do(
1598             sub {
1599                 $self->_result->add_to_item_bundles_hosts(
1600                     { item => $bundle_item->itemnumber } );
1601
1602                 $bundle_item->notforloan($BundleNotLoanValue)->store();
1603             }
1604         );
1605     }
1606     catch {
1607
1608         # FIXME: See if we can move the below copy/paste from Koha::Object::store into it's own class and catch at a lower level in the Schema instantiation, take inspiration from DBIx::Error
1609         if ( ref($_) eq 'DBIx::Class::Exception' ) {
1610             warn $_->{msg};
1611             if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
1612                 # FK constraints
1613                 # FIXME: MySQL error, if we support more DB engines we should implement this for each
1614                 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
1615                     Koha::Exceptions::Object::FKConstraint->throw(
1616                         error     => 'Broken FK constraint',
1617                         broken_fk => $+{column}
1618                     );
1619                 }
1620             }
1621             elsif (
1622                 $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
1623             {
1624                 Koha::Exceptions::Object::DuplicateID->throw(
1625                     error        => 'Duplicate ID',
1626                     duplicate_id => $+{key}
1627                 );
1628             }
1629             elsif ( $_->{msg} =~
1630 /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/
1631               )
1632             {    # The optional \W in the regex might be a quote or backtick
1633                 my $type     = $+{type};
1634                 my $value    = $+{value};
1635                 my $property = $+{property};
1636                 $property =~ s/['`]//g;
1637                 Koha::Exceptions::Object::BadValue->throw(
1638                     type     => $type,
1639                     value    => $value,
1640                     property => $property =~ /(\w+\.\w+)$/
1641                     ? $1
1642                     : $property
1643                     ,    # results in table.column without quotes or backtics
1644                 );
1645             }
1646
1647             # Catch-all for foreign key breakages. It will help find other use cases
1648             $_->rethrow();
1649         }
1650         else {
1651             $_;
1652         }
1653     };
1654 }
1655
1656 =head3 remove_from_bundle
1657
1658 Remove this item from any bundle it may have been attached to.
1659
1660 =cut
1661
1662 sub remove_from_bundle {
1663     my ($self) = @_;
1664
1665     my $bundle_item_rs = $self->_result->item_bundles_item;
1666     if ( $bundle_item_rs ) {
1667         $bundle_item_rs->delete;
1668         $self->notforloan(0)->store();
1669         return 1;
1670     }
1671     return 0;
1672 }
1673
1674 =head2 Internal methods
1675
1676 =head3 _after_item_action_hooks
1677
1678 Helper method that takes care of calling all plugin hooks
1679
1680 =cut
1681
1682 sub _after_item_action_hooks {
1683     my ( $self, $params ) = @_;
1684
1685     my $action = $params->{action};
1686
1687     Koha::Plugins->call(
1688         'after_item_action',
1689         {
1690             action  => $action,
1691             item    => $self,
1692             item_id => $self->itemnumber,
1693         }
1694     );
1695 }
1696
1697 =head3 recall
1698
1699     my $recall = $item->recall;
1700
1701 Return the relevant recall for this item
1702
1703 =cut
1704
1705 sub recall {
1706     my ( $self ) = @_;
1707     my @recalls = Koha::Recalls->search(
1708         {
1709             biblio_id => $self->biblionumber,
1710             completed => 0,
1711         },
1712         { order_by => { -asc => 'created_date' } }
1713     )->as_list;
1714     foreach my $recall (@recalls) {
1715         if ( $recall->item_level and $recall->item_id == $self->itemnumber ){
1716             return $recall;
1717         }
1718     }
1719     # no item-level recall to return, so return earliest biblio-level
1720     # FIXME: eventually this will be based on priority
1721     return $recalls[0];
1722 }
1723
1724 =head3 can_be_recalled
1725
1726     if ( $item->can_be_recalled({ patron => $patron_object }) ) # do recall
1727
1728 Does item-level checks and returns if items can be recalled by this borrower
1729
1730 =cut
1731
1732 sub can_be_recalled {
1733     my ( $self, $params ) = @_;
1734
1735     return 0 if !( C4::Context->preference('UseRecalls') );
1736
1737     # check if this item is not for loan, withdrawn or lost
1738     return 0 if ( $self->notforloan != 0 );
1739     return 0 if ( $self->itemlost != 0 );
1740     return 0 if ( $self->withdrawn != 0 );
1741
1742     # check if this item is not checked out - if not checked out, can't be recalled
1743     return 0 if ( !defined( $self->checkout ) );
1744
1745     my $patron = $params->{patron};
1746
1747     my $branchcode = C4::Context->userenv->{'branch'};
1748     if ( $patron ) {
1749         $branchcode = C4::Circulation::_GetCircControlBranch( $self->unblessed, $patron->unblessed );
1750     }
1751
1752     # Check the circulation rule for each relevant itemtype for this item
1753     my $rule = Koha::CirculationRules->get_effective_rules({
1754         branchcode => $branchcode,
1755         categorycode => $patron ? $patron->categorycode : undef,
1756         itemtype => $self->effective_itemtype,
1757         rules => [
1758             'recalls_allowed',
1759             'recalls_per_record',
1760             'on_shelf_recalls',
1761         ],
1762     });
1763
1764     # check recalls allowed has been set and is not zero
1765     return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
1766
1767     if ( $patron ) {
1768         # check borrower has not reached open recalls allowed limit
1769         return 0 if ( $patron->recalls->filter_by_current->count >= $rule->{recalls_allowed} );
1770
1771         # check borrower has not reach open recalls allowed per record limit
1772         return 0 if ( $patron->recalls->filter_by_current->search({ biblio_id => $self->biblionumber })->count >= $rule->{recalls_per_record} );
1773
1774         # check if this patron has already recalled this item
1775         return 0 if ( Koha::Recalls->search({ item_id => $self->itemnumber, patron_id => $patron->borrowernumber })->filter_by_current->count > 0 );
1776
1777         # check if this patron has already checked out this item
1778         return 0 if ( Koha::Checkouts->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
1779
1780         # check if this patron has already reserved this item
1781         return 0 if ( Koha::Holds->search({ itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber })->count > 0 );
1782     }
1783
1784     # check item availability
1785     # items are unavailable for recall if they are lost, withdrawn or notforloan
1786     my @items = Koha::Items->search({ biblionumber => $self->biblionumber, itemlost => 0, withdrawn => 0, notforloan => 0 })->as_list;
1787
1788     # if there are no available items at all, no recall can be placed
1789     return 0 if ( scalar @items == 0 );
1790
1791     my $checked_out_count = 0;
1792     foreach (@items) {
1793         if ( Koha::Checkouts->search({ itemnumber => $_->itemnumber })->count > 0 ){ $checked_out_count++; }
1794     }
1795
1796     # can't recall if on shelf recalls only allowed when all unavailable, but items are still available for checkout
1797     return 0 if ( $rule->{on_shelf_recalls} eq 'all' && $checked_out_count < scalar @items );
1798
1799     # can't recall if no items have been checked out
1800     return 0 if ( $checked_out_count == 0 );
1801
1802     # can recall
1803     return 1;
1804 }
1805
1806 =head3 can_be_waiting_recall
1807
1808     if ( $item->can_be_waiting_recall ) { # allocate item as waiting for recall
1809
1810 Checks item type and branch of circ rules to return whether this item can be used to fill a recall.
1811 At this point the item has already been recalled. We are now at the checkin and set waiting stage.
1812
1813 =cut
1814
1815 sub can_be_waiting_recall {
1816     my ( $self ) = @_;
1817
1818     return 0 if !( C4::Context->preference('UseRecalls') );
1819
1820     # check if this item is not for loan, withdrawn or lost
1821     return 0 if ( $self->notforloan != 0 );
1822     return 0 if ( $self->itemlost != 0 );
1823     return 0 if ( $self->withdrawn != 0 );
1824
1825     my $branchcode = $self->holdingbranch;
1826     if ( C4::Context->preference('CircControl') eq 'PickupLibrary' and C4::Context->userenv and C4::Context->userenv->{'branch'} ) {
1827         $branchcode = C4::Context->userenv->{'branch'};
1828     } else {
1829         $branchcode = ( C4::Context->preference('HomeOrHoldingBranch') eq 'homebranch' ) ? $self->homebranch : $self->holdingbranch;
1830     }
1831
1832     # Check the circulation rule for each relevant itemtype for this item
1833     my $rule = Koha::CirculationRules->get_effective_rules({
1834         branchcode => $branchcode,
1835         categorycode => undef,
1836         itemtype => $self->effective_itemtype,
1837         rules => [
1838             'recalls_allowed',
1839         ],
1840     });
1841
1842     # check recalls allowed has been set and is not zero
1843     return 0 if ( !defined($rule->{recalls_allowed}) || $rule->{recalls_allowed} == 0 );
1844
1845     # can recall
1846     return 1;
1847 }
1848
1849 =head3 check_recalls
1850
1851     my $recall = $item->check_recalls;
1852
1853 Get the most relevant recall for this item.
1854
1855 =cut
1856
1857 sub check_recalls {
1858     my ( $self ) = @_;
1859
1860     my @recalls = Koha::Recalls->search(
1861         {   biblio_id => $self->biblionumber,
1862             item_id   => [ $self->itemnumber, undef ]
1863         },
1864         { order_by => { -asc => 'created_date' } }
1865     )->filter_by_current->as_list;
1866
1867     my $recall;
1868     # iterate through relevant recalls to find the best one.
1869     # if we come across a waiting recall, use this one.
1870     # if we have iterated through all recalls and not found a waiting recall, use the first recall in the array, which should be the oldest recall.
1871     foreach my $r ( @recalls ) {
1872         if ( $r->waiting ) {
1873             $recall = $r;
1874             last;
1875         }
1876     }
1877     unless ( defined $recall ) {
1878         $recall = $recalls[0];
1879     }
1880
1881     return $recall;
1882 }
1883
1884 =head3 is_notforloan
1885
1886     my $is_notforloan = $item->is_notforloan;
1887
1888 Determine whether or not this item is "notforloan" based on
1889 the item's notforloan status or its item type
1890
1891 =cut
1892
1893 sub is_notforloan {
1894     my ( $self ) = @_;
1895     my $is_notforloan = 0;
1896
1897     if ( $self->notforloan ){
1898         $is_notforloan = 1;
1899     }
1900     else {
1901         my $itemtype = $self->itemtype;
1902         if ($itemtype){
1903             if ( $itemtype->notforloan ){
1904                 $is_notforloan = 1;
1905             }
1906         }
1907     }
1908
1909     return $is_notforloan;
1910 }
1911
1912 =head3 _type
1913
1914 =cut
1915
1916 sub _type {
1917     return 'Item';
1918 }
1919
1920 =head1 AUTHOR
1921
1922 Kyle M Hall <kyle@bywatersolutions.com>
1923
1924 =cut
1925
1926 1;