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