Bug 23463: Remove DelItemCheck and ItemSafeToDelete
[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 Carp;
23 use List::MoreUtils qw(any);
24 use Data::Dumper;
25 use Try::Tiny;
26
27 use Koha::Database;
28 use Koha::DateUtils qw( dt_from_string );
29
30 use C4::Context;
31 use C4::Circulation;
32 use C4::Reserves;
33 use C4::Biblio qw( ModZebra ); # FIXME This is terrible, we should move the indexation code outside of C4::Biblio
34 use C4::ClassSource; # FIXME We would like to avoid that
35 use C4::Log qw( logaction );
36
37 use Koha::Checkouts;
38 use Koha::CirculationRules;
39 use Koha::Item::Transfer::Limits;
40 use Koha::Item::Transfers;
41 use Koha::Patrons;
42 use Koha::Plugins;
43 use Koha::Libraries;
44 use Koha::StockRotationItem;
45 use Koha::StockRotationRotas;
46
47 use base qw(Koha::Object);
48
49 =head1 NAME
50
51 Koha::Item - Koha Item object class
52
53 =head1 API
54
55 =head2 Class methods
56
57 =cut
58
59 =head3 store
60
61 =cut
62
63 sub store {
64     my ($self, $params) = @_;
65
66     my $log_action = $params->{log_action} // 1;
67
68     # We do not want to oblige callers to pass this value
69     # Dev conveniences vs performance?
70     unless ( $self->biblioitemnumber ) {
71         $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
72     }
73
74     # See related changes from C4::Items::AddItem
75     unless ( $self->itype ) {
76         $self->itype($self->biblio->biblioitem->itemtype);
77     }
78
79     if ( $self->itemcallnumber ) { # This could be improved, we should recalculate it only if changed
80         my $cn_sort = GetClassSort($self->cn_source, $self->itemcallnumber, "");
81         $self->cn_sort($cn_sort);
82     }
83
84     my $today = dt_from_string;
85     unless ( $self->in_storage ) { #AddItem
86         unless ( $self->permanent_location ) {
87             $self->permanent_location($self->location);
88         }
89         unless ( $self->replacementpricedate ) {
90             $self->replacementpricedate($today);
91         }
92         unless ( $self->datelastseen ) {
93             $self->datelastseen($today);
94         }
95
96         unless ( $self->dateaccessioned ) {
97             $self->dateaccessioned($today);
98         }
99
100         C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" );
101
102         logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
103           if $log_action && C4::Context->preference("CataloguingLog");
104
105         $self->_after_item_action_hooks({ action => 'create' });
106
107     } else { # ModItem
108
109         { # Update *_on  fields if needed
110           # Why not for AddItem as well?
111             my @fields = qw( itemlost withdrawn damaged );
112
113             # Only retrieve the item if we need to set an "on" date field
114             if ( $self->itemlost || $self->withdrawn || $self->damaged ) {
115                 my $pre_mod_item = $self->get_from_storage;
116                 for my $field (@fields) {
117                     if (    $self->$field
118                         and not $pre_mod_item->$field )
119                     {
120                         my $field_on = "${field}_on";
121                         $self->$field_on(
122                           DateTime::Format::MySQL->format_datetime( dt_from_string() )
123                         );
124                     }
125                 }
126             }
127
128             # If the field is defined but empty, we are removing and,
129             # and thus need to clear out the 'on' field as well
130             for my $field (@fields) {
131                 if ( defined( $self->$field ) && !$self->$field ) {
132                     my $field_on = "${field}_on";
133                     $self->$field_on(undef);
134                 }
135             }
136         }
137
138         if ( defined $self->location and $self->location ne 'CART' and $self->location ne 'PROC' and not $self->permanent_location ) {
139             $self->permanent_location($self->location);
140         }
141
142         $self->timestamp(undef) if $self->timestamp; # Maybe move this to Koha::Object->store?
143
144         C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" );
145
146         $self->_after_item_action_hooks({ action => 'modify' });
147
148         logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper($self->unblessed) )
149           if $log_action && C4::Context->preference("CataloguingLog");
150     }
151
152     unless ( $self->dateaccessioned ) {
153         $self->dateaccessioned($today);
154     }
155
156     return $self->SUPER::store;
157 }
158
159 =head3 delete
160
161 =cut
162
163 sub delete {
164     my ( $self ) = @_;
165
166     # FIXME check the item has no current issues
167     # i.e. raise the appropriate exception
168
169     C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" );
170
171     $self->_after_item_action_hooks({ action => 'delete' });
172
173     logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
174       if C4::Context->preference("CataloguingLog");
175
176     return $self->SUPER::delete;
177 }
178
179 =head3 safe_delete
180
181 =cut
182
183 sub safe_delete {
184     my ($self) = @_;
185
186     my $safe_to_delete = $self->safe_to_delete;
187     return $safe_to_delete unless $safe_to_delete eq '1';
188
189     $self->move_to_deleted;
190
191     return $self->delete;
192 }
193
194 =head3 safe_to_delete
195
196 returns 1 if the item is safe to delete,
197
198 "book_on_loan" if the item is checked out,
199
200 "not_same_branch" if the item is blocked by independent branches,
201
202 "book_reserved" if the there are holds aganst the item, or
203
204 "linked_analytics" if the item has linked analytic records.
205
206 =cut
207
208 sub safe_to_delete {
209     my ($self) = @_;
210
211     return "book_on_loan" if $self->checkout;
212
213     return "not_same_branch"
214       if defined C4::Context->userenv
215       and !C4::Context->IsSuperLibrarian()
216       and C4::Context->preference("IndependentBranches")
217       and ( C4::Context->userenv->{branch} ne $self->homebranch );
218
219     # check it doesn't have a waiting reserve
220     return "book_reserved"
221       if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
222
223     return "linked_analytics"
224       if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
225
226     return 1;
227 }
228
229 =head3 move_to_deleted
230
231 my $is_moved = $item->move_to_deleted;
232
233 Move an item to the deleteditems table.
234 This can be done before deleting an item, to make sure the data are not completely deleted.
235
236 =cut
237
238 sub move_to_deleted {
239     my ($self) = @_;
240     my $item_infos = $self->unblessed;
241     delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
242     return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
243 }
244
245
246 =head3 effective_itemtype
247
248 Returns the itemtype for the item based on whether item level itemtypes are set or not.
249
250 =cut
251
252 sub effective_itemtype {
253     my ( $self ) = @_;
254
255     return $self->_result()->effective_itemtype();
256 }
257
258 =head3 home_branch
259
260 =cut
261
262 sub home_branch {
263     my ($self) = @_;
264
265     $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
266
267     return $self->{_home_branch};
268 }
269
270 =head3 holding_branch
271
272 =cut
273
274 sub holding_branch {
275     my ($self) = @_;
276
277     $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
278
279     return $self->{_holding_branch};
280 }
281
282 =head3 biblio
283
284 my $biblio = $item->biblio;
285
286 Return the bibliographic record of this item
287
288 =cut
289
290 sub biblio {
291     my ( $self ) = @_;
292     my $biblio_rs = $self->_result->biblio;
293     return Koha::Biblio->_new_from_dbic( $biblio_rs );
294 }
295
296 =head3 biblioitem
297
298 my $biblioitem = $item->biblioitem;
299
300 Return the biblioitem record of this item
301
302 =cut
303
304 sub biblioitem {
305     my ( $self ) = @_;
306     my $biblioitem_rs = $self->_result->biblioitem;
307     return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
308 }
309
310 =head3 checkout
311
312 my $checkout = $item->checkout;
313
314 Return the checkout for this item
315
316 =cut
317
318 sub checkout {
319     my ( $self ) = @_;
320     my $checkout_rs = $self->_result->issue;
321     return unless $checkout_rs;
322     return Koha::Checkout->_new_from_dbic( $checkout_rs );
323 }
324
325 =head3 holds
326
327 my $holds = $item->holds();
328 my $holds = $item->holds($params);
329 my $holds = $item->holds({ found => 'W'});
330
331 Return holds attached to an item, optionally accept a hashref of params to pass to search
332
333 =cut
334
335 sub holds {
336     my ( $self,$params ) = @_;
337     my $holds_rs = $self->_result->reserves->search($params);
338     return Koha::Holds->_new_from_dbic( $holds_rs );
339 }
340
341 =head3 get_transfer
342
343 my $transfer = $item->get_transfer;
344
345 Return the transfer if the item is in transit or undef
346
347 =cut
348
349 sub get_transfer {
350     my ( $self ) = @_;
351     my $transfer_rs = $self->_result->branchtransfers->search({ datearrived => undef })->first;
352     return unless $transfer_rs;
353     return Koha::Item::Transfer->_new_from_dbic( $transfer_rs );
354 }
355
356 =head3 last_returned_by
357
358 Gets and sets the last borrower to return an item.
359
360 Accepts and returns Koha::Patron objects
361
362 $item->last_returned_by( $borrowernumber );
363
364 $last_returned_by = $item->last_returned_by();
365
366 =cut
367
368 sub last_returned_by {
369     my ( $self, $borrower ) = @_;
370
371     my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
372
373     if ($borrower) {
374         return $items_last_returned_by_rs->update_or_create(
375             { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
376     }
377     else {
378         unless ( $self->{_last_returned_by} ) {
379             my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
380             if ($result) {
381                 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
382             }
383         }
384
385         return $self->{_last_returned_by};
386     }
387 }
388
389 =head3 can_article_request
390
391 my $bool = $item->can_article_request( $borrower )
392
393 Returns true if item can be specifically requested
394
395 $borrower must be a Koha::Patron object
396
397 =cut
398
399 sub can_article_request {
400     my ( $self, $borrower ) = @_;
401
402     my $rule = $self->article_request_type($borrower);
403
404     return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
405     return q{};
406 }
407
408 =head3 hidden_in_opac
409
410 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
411
412 Returns true if item fields match the hidding criteria defined in $rules.
413 Returns false otherwise.
414
415 Takes HASHref that can have the following parameters:
416     OPTIONAL PARAMETERS:
417     $rules : { <field> => [ value_1, ... ], ... }
418
419 Note: $rules inherits its structure from the parsed YAML from reading
420 the I<OpacHiddenItems> system preference.
421
422 =cut
423
424 sub hidden_in_opac {
425     my ( $self, $params ) = @_;
426
427     my $rules = $params->{rules} // {};
428
429     return 1
430         if C4::Context->preference('hidelostitems') and
431            $self->itemlost > 0;
432
433     my $hidden_in_opac = 0;
434
435     foreach my $field ( keys %{$rules} ) {
436
437         if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
438             $hidden_in_opac = 1;
439             last;
440         }
441     }
442
443     return $hidden_in_opac;
444 }
445
446 =head3 can_be_transferred
447
448 $item->can_be_transferred({ to => $to_library, from => $from_library })
449 Checks if an item can be transferred to given library.
450
451 This feature is controlled by two system preferences:
452 UseBranchTransferLimits to enable / disable the feature
453 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
454                          for setting the limitations
455
456 Takes HASHref that can have the following parameters:
457     MANDATORY PARAMETERS:
458     $to   : Koha::Library
459     OPTIONAL PARAMETERS:
460     $from : Koha::Library  # if not given, item holdingbranch
461                            # will be used instead
462
463 Returns 1 if item can be transferred to $to_library, otherwise 0.
464
465 To find out whether at least one item of a Koha::Biblio can be transferred, please
466 see Koha::Biblio->can_be_transferred() instead of using this method for
467 multiple items of the same biblio.
468
469 =cut
470
471 sub can_be_transferred {
472     my ($self, $params) = @_;
473
474     my $to   = $params->{to};
475     my $from = $params->{from};
476
477     $to   = $to->branchcode;
478     $from = defined $from ? $from->branchcode : $self->holdingbranch;
479
480     return 1 if $from eq $to; # Transfer to current branch is allowed
481     return 1 unless C4::Context->preference('UseBranchTransferLimits');
482
483     my $limittype = C4::Context->preference('BranchTransferLimitsType');
484     return Koha::Item::Transfer::Limits->search({
485         toBranch => $to,
486         fromBranch => $from,
487         $limittype => $limittype eq 'itemtype'
488                         ? $self->effective_itemtype : $self->ccode
489     })->count ? 0 : 1;
490 }
491
492 =head3 pickup_locations
493
494 @pickup_locations = $item->pickup_locations( {patron => $patron } )
495
496 Returns possible pickup locations for this item, according to patron's home library (if patron is defined and holds are allowed only from hold groups)
497 and if item can be transferred to each pickup location.
498
499 =cut
500
501 sub pickup_locations {
502     my ($self, $params) = @_;
503
504     my $patron = $params->{patron};
505
506     my $circ_control_branch =
507       C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
508     my $branchitemrule =
509       C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
510
511     my @libs;
512     if(defined $patron) {
513         return @libs if $branchitemrule->{holdallowed} == 3 && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
514         return @libs if $branchitemrule->{holdallowed} == 1 && $self->home_branch->branchcode ne $patron->branchcode;
515     }
516
517     if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
518         @libs  = $self->home_branch->get_hold_libraries;
519         push @libs, $self->home_branch unless scalar(@libs) > 0;
520     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
521         my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
522         @libs  = $plib->get_hold_libraries;
523         push @libs, $self->home_branch unless scalar(@libs) > 0;
524     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
525         push @libs, $self->home_branch;
526     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
527         push @libs, $self->holding_branch;
528     } else {
529         @libs = Koha::Libraries->search({
530             pickup_location => 1
531         }, {
532             order_by => ['branchname']
533         })->as_list;
534     }
535
536     my @pickup_locations;
537     foreach my $library (@libs) {
538         if ($library->pickup_location && $self->can_be_transferred({ to => $library })) {
539             push @pickup_locations, $library;
540         }
541     }
542
543     return wantarray ? @pickup_locations : \@pickup_locations;
544 }
545
546 =head3 article_request_type
547
548 my $type = $item->article_request_type( $borrower )
549
550 returns 'yes', 'no', 'bib_only', or 'item_only'
551
552 $borrower must be a Koha::Patron object
553
554 =cut
555
556 sub article_request_type {
557     my ( $self, $borrower ) = @_;
558
559     my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
560     my $branchcode =
561         $branch_control eq 'homebranch'    ? $self->homebranch
562       : $branch_control eq 'holdingbranch' ? $self->holdingbranch
563       :                                      undef;
564     my $borrowertype = $borrower->categorycode;
565     my $itemtype = $self->effective_itemtype();
566     my $rule = Koha::CirculationRules->get_effective_rule(
567         {
568             rule_name    => 'article_requests',
569             categorycode => $borrowertype,
570             itemtype     => $itemtype,
571             branchcode   => $branchcode
572         }
573     );
574
575     return q{} unless $rule;
576     return $rule->rule_value || q{}
577 }
578
579 =head3 current_holds
580
581 =cut
582
583 sub current_holds {
584     my ( $self ) = @_;
585     my $attributes = { order_by => 'priority' };
586     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
587     my $params = {
588         itemnumber => $self->itemnumber,
589         suspend => 0,
590         -or => [
591             reservedate => { '<=' => $dtf->format_date(dt_from_string) },
592             waitingdate => { '!=' => undef },
593         ],
594     };
595     my $hold_rs = $self->_result->reserves->search( $params, $attributes );
596     return Koha::Holds->_new_from_dbic($hold_rs);
597 }
598
599 =head3 holds
600
601 =cut
602
603 sub holds {
604     my ( $self ) = @_;
605     my $hold_rs = $self->_result->reserves->search;
606     return Koha::Holds->_new_from_dbic($hold_rs);
607 }
608
609 =head3 stockrotationitem
610
611   my $sritem = Koha::Item->stockrotationitem;
612
613 Returns the stock rotation item associated with the current item.
614
615 =cut
616
617 sub stockrotationitem {
618     my ( $self ) = @_;
619     my $rs = $self->_result->stockrotationitem;
620     return 0 if !$rs;
621     return Koha::StockRotationItem->_new_from_dbic( $rs );
622 }
623
624 =head3 add_to_rota
625
626   my $item = $item->add_to_rota($rota_id);
627
628 Add this item to the rota identified by $ROTA_ID, which means associating it
629 with the first stage of that rota.  Should this item already be associated
630 with a rota, then we will move it to the new rota.
631
632 =cut
633
634 sub add_to_rota {
635     my ( $self, $rota_id ) = @_;
636     Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
637     return $self;
638 }
639
640 =head3 has_pending_hold
641
642   my $is_pending_hold = $item->has_pending_hold();
643
644 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
645
646 =cut
647
648 sub has_pending_hold {
649     my ( $self ) = @_;
650     my $pending_hold = $self->_result->tmp_holdsqueues;
651     return $pending_hold->count ? 1: 0;
652 }
653
654 =head3 as_marc_field
655
656     my $mss   = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
657     my $field = $item->as_marc_field({ [ mss => $mss ] });
658
659 This method returns a MARC::Field object representing the Koha::Item object
660 with the current mappings configuration.
661
662 =cut
663
664 sub as_marc_field {
665     my ( $self, $params ) = @_;
666
667     my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
668     my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
669
670     my @subfields;
671
672     my @columns = $self->_result->result_source->columns;
673
674     foreach my $item_field ( @columns ) {
675         my $mapping = $mss->{ "items.$item_field"}[0];
676         my $tagfield    = $mapping->{tagfield};
677         my $tagsubfield = $mapping->{tagsubfield};
678         next if !$tagfield; # TODO: Should we raise an exception instead?
679                             # Feels like safe fallback is better
680
681         push @subfields, $tagsubfield => $self->$item_field;
682     }
683
684     my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
685     push( @subfields, @{$unlinked_item_subfields} )
686         if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
687
688     my $field;
689
690     $field = MARC::Field->new(
691         "$item_tag", ' ', ' ', @subfields
692     ) if @subfields;
693
694     return $field;
695 }
696
697 =head3 to_api_mapping
698
699 This method returns the mapping for representing a Koha::Item object
700 on the API.
701
702 =cut
703
704 sub to_api_mapping {
705     return {
706         itemnumber               => 'item_id',
707         biblionumber             => 'biblio_id',
708         biblioitemnumber         => undef,
709         barcode                  => 'external_id',
710         dateaccessioned          => 'acquisition_date',
711         booksellerid             => 'acquisition_source',
712         homebranch               => 'home_library_id',
713         price                    => 'purchase_price',
714         replacementprice         => 'replacement_price',
715         replacementpricedate     => 'replacement_price_date',
716         datelastborrowed         => 'last_checkout_date',
717         datelastseen             => 'last_seen_date',
718         stack                    => undef,
719         notforloan               => 'not_for_loan_status',
720         damaged                  => 'damaged_status',
721         damaged_on               => 'damaged_date',
722         itemlost                 => 'lost_status',
723         itemlost_on              => 'lost_date',
724         withdrawn                => 'withdrawn',
725         withdrawn_on             => 'withdrawn_date',
726         itemcallnumber           => 'callnumber',
727         coded_location_qualifier => 'coded_location_qualifier',
728         issues                   => 'checkouts_count',
729         renewals                 => 'renewals_count',
730         reserves                 => 'holds_count',
731         restricted               => 'restricted_status',
732         itemnotes                => 'public_notes',
733         itemnotes_nonpublic      => 'internal_notes',
734         holdingbranch            => 'holding_library_id',
735         paidfor                  => undef,
736         timestamp                => 'timestamp',
737         location                 => 'location',
738         permanent_location       => 'permanent_location',
739         onloan                   => 'checked_out_date',
740         cn_source                => 'call_number_source',
741         cn_sort                  => 'call_number_sort',
742         ccode                    => 'collection_code',
743         materials                => 'materials_notes',
744         uri                      => 'uri',
745         itype                    => 'item_type',
746         more_subfields_xml       => 'extended_subfields',
747         enumchron                => 'serial_issue_number',
748         copynumber               => 'copy_number',
749         stocknumber              => 'inventory_number',
750         new_status               => 'new_status'
751     };
752 }
753
754 =head2 Internal methods
755
756 =head3 _after_item_action_hooks
757
758 Helper method that takes care of calling all plugin hooks
759
760 =cut
761
762 sub _after_item_action_hooks {
763     my ( $self, $params ) = @_;
764
765     my $action = $params->{action};
766
767     if ( C4::Context->preference('UseKohaPlugins') && C4::Context->config("enable_plugins") ) {
768
769         my @plugins = Koha::Plugins->new->GetPlugins({
770             method => 'after_item_action',
771         });
772
773         if (@plugins) {
774
775             foreach my $plugin ( @plugins ) {
776                 try {
777                     $plugin->after_item_action({ action => $action, item => $self, item_id => $self->itemnumber });
778                 }
779                 catch {
780                     warn "$_";
781                 };
782             }
783         }
784     }
785 }
786
787 =head3 _type
788
789 =cut
790
791 sub _type {
792     return 'Item';
793 }
794
795 =head1 AUTHOR
796
797 Kyle M Hall <kyle@bywatersolutions.com>
798
799 =cut
800
801 1;