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