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