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