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