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