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