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