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