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