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