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