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