Bug 33146: Unit tests
[koha-ffzg.git] / Koha / Item.pm
index e978673..0d29655 100644 (file)
@@ -36,14 +36,18 @@ use Koha::Biblio::ItemGroups;
 use Koha::Checkouts;
 use Koha::CirculationRules;
 use Koha::CoverImages;
+use Koha::Exceptions::Checkin;
+use Koha::Exceptions::Item::Bundle;
 use Koha::Exceptions::Item::Transfer;
 use Koha::Item::Attributes;
+use Koha::Exceptions::Item::Bundle;
 use Koha::Item::Transfer::Limits;
 use Koha::Item::Transfers;
 use Koha::ItemTypes;
 use Koha::Libraries;
 use Koha::Patrons;
 use Koha::Plugins;
+use Koha::Recalls;
 use Koha::Result::Boolean;
 use Koha::SearchEngine::Indexer;
 use Koha::StockRotationItem;
@@ -68,10 +72,6 @@ Koha::Item - Koha Item object class
 
 $params can take an optional 'skip_record_index' parameter.
 If set, the reindexation process will not happen (index_records not called)
-
-NOTE: This is a temporary fix to answer a performance issue when lot of items
-are added (or modified) at the same time.
-The correct way to fix this is to make the ES reindexation process async.
 You should not turn it on if you do not understand what it is doing exactly.
 
 =cut
@@ -165,11 +165,7 @@ sub store {
                 && !$pre_mod_item->$field )
             {
                 my $field_on = "${field}_on";
-                $self->$field_on(
-                    DateTime::Format::MySQL->format_datetime(
-                        dt_from_string()
-                    )
-                );
+                $self->$field_on(dt_from_string);
             }
         }
 
@@ -182,8 +178,7 @@ sub store {
 
 
         if (    exists $updated_columns{location}
-            and $self->location ne 'CART'
-            and $self->location ne 'PROC'
+            and ( !defined($self->location) or $self->location !~ /^(CART|PROC)$/ )
             and not exists $updated_columns{permanent_location} )
         {
             $self->permanent_location( $self->location );
@@ -296,20 +291,19 @@ sub safe_to_delete {
 
     $error = "book_on_loan" if $self->checkout;
 
-    $error = "not_same_branch"
+    $error //= "not_same_branch"
       if defined C4::Context->userenv
-      and !C4::Context->IsSuperLibrarian()
-      and C4::Context->preference("IndependentBranches")
-      and ( C4::Context->userenv->{branch} ne $self->homebranch );
+      and defined C4::Context->userenv->{number}
+      and !Koha::Patrons->find( C4::Context->userenv->{number} )->can_edit_items_from( $self->homebranch );
 
     # check it doesn't have a waiting reserve
-    $error = "book_reserved"
-      if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
+    $error //= "book_reserved"
+      if $self->holds->filter_by_found->count;
 
-    $error = "linked_analytics"
+    $error //= "linked_analytics"
       if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
 
-    $error = "last_item_for_hold"
+    $error //= "last_item_for_hold"
       if $self->biblio->items->count == 1
       && $self->biblio->holds->search(
           {
@@ -337,6 +331,7 @@ sub move_to_deleted {
     my ($self) = @_;
     my $item_infos = $self->unblessed;
     delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
+    $item_infos->{deleted_on} = dt_from_string;
     return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
 }
 
@@ -360,9 +355,9 @@ sub effective_itemtype {
 sub home_branch {
     my ($self) = @_;
 
-    $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
+    my $hb_rs = $self->_result->homebranch;
 
-    return $self->{_home_branch};
+    return Koha::Library->_new_from_dbic( $hb_rs );
 }
 
 =head3 holding_branch
@@ -372,9 +367,9 @@ sub home_branch {
 sub holding_branch {
     my ($self) = @_;
 
-    $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
+    my $hb_rs = $self->_result->holdingbranch;
 
-    return $self->{_holding_branch};
+    return Koha::Library->_new_from_dbic( $hb_rs );
 }
 
 =head3 biblio
@@ -568,19 +563,8 @@ we still expect the item to end up at a final location eventually.
 
 sub get_transfer {
     my ($self) = @_;
-    my $transfer_rs = $self->_result->branchtransfers->search(
-        {
-            datearrived   => undef,
-            datecancelled => undef
-        },
-        {
-            order_by =>
-              [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
-            rows => 1
-        }
-    )->first;
-    return unless $transfer_rs;
-    return Koha::Item::Transfer->_new_from_dbic($transfer_rs);
+
+    return $self->get_transfers->search( {}, { rows => 1 } )->next;
 }
 
 =head3 get_transfers
@@ -602,17 +586,13 @@ we still expect the item to end up at a final location eventually.
 
 sub get_transfers {
     my ($self) = @_;
-    my $transfer_rs = $self->_result->branchtransfers->search(
-        {
-            datearrived   => undef,
-            datecancelled => undef
-        },
-        {
-            order_by =>
-              [ { -desc => 'datesent' }, { -asc => 'daterequested' } ],
-        }
-    );
-    return Koha::Item::Transfers->_new_from_dbic($transfer_rs);
+
+    my $transfer_rs = $self->_result->branchtransfers;
+
+    return Koha::Item::Transfers
+                ->_new_from_dbic($transfer_rs)
+                ->filter_by_current
+                ->search( {}, { order_by => [ { -desc => 'datesent' }, { -asc => 'daterequested' } ], } );
 }
 
 =head3 last_returned_by
@@ -924,6 +904,26 @@ sub has_pending_hold {
     return $pending_hold->count ? 1: 0;
 }
 
+=head3 has_pending_recall {
+
+  my $has_pending_recall
+
+Return if whether has pending recall of not.
+
+=cut
+
+sub has_pending_recall {
+    my ( $self ) = @_;
+
+    # FIXME Must be moved to $self->recalls
+    return Koha::Recalls->search(
+        {
+            item_id   => $self->itemnumber,
+            status    => 'waiting',
+        }
+    )->count;
+}
+
 =head3 as_marc_field
 
     my $field = $item->as_marc_field;
@@ -955,7 +955,7 @@ sub as_marc_field {
         my $kohafield = $subfield->{kohafield};
         my $tagsubfield = $subfield->{tagsubfield};
         my $value;
-        if ( defined $kohafield ) {
+        if ( defined $kohafield && $kohafield ne '' ) {
             next if $kohafield !~ m{^items\.}; # That would be weird!
             ( my $attribute = $kohafield ) =~ s|^items\.||;
             $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
@@ -1157,7 +1157,7 @@ sub _set_found_trigger {
         return $self unless $lost_age_in_days < $no_refund_after_days;
     }
 
-    my $lostreturn_policy = Koha::CirculationRules->get_lostreturn_policy(
+    my $lost_proc_return_policy = Koha::CirculationRules->get_lostreturn_policy(
         {
             item          => $self,
             return_branch => C4::Context->userenv
@@ -1165,6 +1165,7 @@ sub _set_found_trigger {
             : undef,
         }
       );
+    my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
 
     if ( $lostreturn_policy ) {
 
@@ -1187,14 +1188,26 @@ sub _set_found_trigger {
             if ( $patron ) {
 
                 my $account = $patron->account;
-                my $total_to_refund = 0;
 
-                # Use cases
-                if ( $lost_charge->amount > $lost_charge->amountoutstanding ) {
+                # Credit outstanding amount
+                my $credit_total = $lost_charge->amountoutstanding;
 
+                # Use cases
+                if (
+                    $lost_charge->amount > $lost_charge->amountoutstanding &&
+                    $lostreturn_policy ne "refund_unpaid"
+                ) {
                     # some amount has been cancelled. collect the offsets that are not writeoffs
                     # this works because the only way to subtract from this kind of a debt is
                     # using the UI buttons 'Pay' and 'Write off'
+
+                    # We don't credit any payments if return policy is
+                    # "refund_unpaid"
+                    #
+                    # In that case only unpaid/outstanding amount
+                    # will be credited which settles the debt without
+                    # creating extra credits
+
                     my $credit_offsets = $lost_charge->debit_offsets(
                         {
                             'credit_id'               => { '!=' => undef },
@@ -1203,13 +1216,15 @@ sub _set_found_trigger {
                         { join => 'credit' }
                     );
 
-                    $total_to_refund = ( $credit_offsets->count > 0 )
-                      ? $credit_offsets->total * -1    # credits are negative on the DB
-                      : 0;
+                    my $total_to_refund = ( $credit_offsets->count > 0 ) ?
+                        # credits are negative on the DB
+                        $credit_offsets->total * -1 :
+                        0;
+                    # Credit the outstanding amount, then add what has been
+                    # paid to create a net credit for this amount
+                    $credit_total += $total_to_refund;
                 }
 
-                my $credit_total = $lost_charge->amountoutstanding + $total_to_refund;
-
                 my $credit;
                 if ( $credit_total > 0 ) {
                     my $branchcode =
@@ -1247,58 +1262,56 @@ sub _set_found_trigger {
             }
         }
 
-        # restore fine for lost book
-        if ( $lostreturn_policy eq 'restore' ) {
-            my $lost_overdue = Koha::Account::Lines->search(
-                {
-                    itemnumber      => $self->itemnumber,
-                    debit_type_code => 'OVERDUE',
-                    status          => 'LOST'
-                },
-                {
-                    order_by => { '-desc' => 'date' },
-                    rows     => 1
-                }
-            )->single;
-
-            if ( $lost_overdue ) {
-
-                my $patron = $lost_overdue->patron;
-                if ($patron) {
-                    my $account = $patron->account;
+        # possibly restore fine for lost book
+        my $lost_overdue = Koha::Account::Lines->search(
+            {
+                itemnumber      => $self->itemnumber,
+                debit_type_code => 'OVERDUE',
+                status          => 'LOST'
+            },
+            {
+                order_by => { '-desc' => 'date' },
+                rows     => 1
+            }
+        )->single;
+        if ( $lostreturn_policy eq 'restore' && $lost_overdue ) {
 
-                    # Update status of fine
-                    $lost_overdue->status('FOUND')->store();
+            my $patron = $lost_overdue->patron;
+            if ($patron) {
+                my $account = $patron->account;
 
-                    # Find related forgive credit
-                    my $refund = $lost_overdue->credits(
+                # Update status of fine
+                $lost_overdue->status('FOUND')->store();
+
+                # Find related forgive credit
+                my $refund = $lost_overdue->credits(
+                    {
+                        credit_type_code => 'FORGIVEN',
+                        itemnumber       => $self->itemnumber,
+                        status           => [ { '!=' => 'VOID' }, undef ]
+                    },
+                    { order_by => { '-desc' => 'date' }, rows => 1 }
+                )->single;
+
+                if ( $refund ) {
+                    # Revert the forgive credit
+                    $refund->void({ interface => 'trigger' });
+                    $self->add_message(
                         {
-                            credit_type_code => 'FORGIVEN',
-                            itemnumber       => $self->itemnumber,
-                            status           => [ { '!=' => 'VOID' }, undef ]
-                        },
-                        { order_by => { '-desc' => 'date' }, rows => 1 }
-                    )->single;
-
-                    if ( $refund ) {
-                        # Revert the forgive credit
-                        $refund->void({ interface => 'trigger' });
-                        $self->add_message(
-                            {
-                                type    => 'info',
-                                message => 'lost_restored',
-                                payload => { refund_id => $refund->id }
-                            }
-                        );
-                    }
+                            type    => 'info',
+                            message => 'lost_restored',
+                            payload => { refund_id => $refund->id }
+                        }
+                    );
+                }
 
-                    # Reconcile balances if required
-                    if ( C4::Context->preference('AccountAutoReconcile') ) {
-                        $account->reconcile_balance;
-                    }
+                # Reconcile balances if required
+                if ( C4::Context->preference('AccountAutoReconcile') ) {
+                    $account->reconcile_balance;
                 }
             }
-        } elsif ( $lostreturn_policy eq 'charge' ) {
+
+        } elsif ( $lostreturn_policy eq 'charge' && ( $lost_overdue || $lost_charge ) ) {
             $self->add_message(
                 {
                     type    => 'info',
@@ -1308,6 +1321,104 @@ sub _set_found_trigger {
         }
     }
 
+    my $processingreturn_policy = $lost_proc_return_policy->{processingreturn};
+
+    if ( $processingreturn_policy ) {
+
+        # refund processing charge made for lost book
+        my $processing_charge = Koha::Account::Lines->search(
+            {
+                itemnumber      => $self->itemnumber,
+                debit_type_code => 'PROCESSING',
+                status          => [ undef, { '<>' => 'FOUND' } ]
+            },
+            {
+                order_by => { -desc => [ 'date', 'accountlines_id' ] },
+                rows     => 1
+            }
+        )->single;
+
+        if ( $processing_charge ) {
+
+            my $patron = $processing_charge->patron;
+            if ( $patron ) {
+
+                my $account = $patron->account;
+
+                # Credit outstanding amount
+                my $credit_total = $processing_charge->amountoutstanding;
+
+                # Use cases
+                if (
+                    $processing_charge->amount > $processing_charge->amountoutstanding &&
+                    $processingreturn_policy ne "refund_unpaid"
+                ) {
+                    # some amount has been cancelled. collect the offsets that are not writeoffs
+                    # this works because the only way to subtract from this kind of a debt is
+                    # using the UI buttons 'Pay' and 'Write off'
+
+                    # We don't credit any payments if return policy is
+                    # "refund_unpaid"
+                    #
+                    # In that case only unpaid/outstanding amount
+                    # will be credited which settles the debt without
+                    # creating extra credits
+
+                    my $credit_offsets = $processing_charge->debit_offsets(
+                        {
+                            'credit_id'               => { '!=' => undef },
+                            'credit.credit_type_code' => { '!=' => 'Writeoff' }
+                        },
+                        { join => 'credit' }
+                    );
+
+                    my $total_to_refund = ( $credit_offsets->count > 0 ) ?
+                        # credits are negative on the DB
+                        $credit_offsets->total * -1 :
+                        0;
+                    # Credit the outstanding amount, then add what has been
+                    # paid to create a net credit for this amount
+                    $credit_total += $total_to_refund;
+                }
+
+                my $credit;
+                if ( $credit_total > 0 ) {
+                    my $branchcode =
+                      C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
+                    $credit = $account->add_credit(
+                        {
+                            amount      => $credit_total,
+                            description => 'Item found ' . $self->itemnumber,
+                            type        => 'PROCESSING_FOUND',
+                            interface   => C4::Context->interface,
+                            library_id  => $branchcode,
+                            item_id     => $self->itemnumber,
+                            issue_id    => $processing_charge->issue_id
+                        }
+                    );
+
+                    $credit->apply( { debits => [$processing_charge] } );
+                    $self->add_message(
+                        {
+                            type    => 'info',
+                            message => 'processing_refunded',
+                            payload => { credit_id => $credit->id }
+                        }
+                    );
+                }
+
+                # Update the account status
+                $processing_charge->status('FOUND');
+                $processing_charge->store();
+
+                # Reconcile balances if required
+                if ( C4::Context->preference('AccountAutoReconcile') ) {
+                    $account->reconcile_balance;
+                }
+            }
+        }
+    }
+
     return $self;
 }
 
@@ -1329,6 +1440,24 @@ sub public_read_list {
     ];
 }
 
+=head3 to_api
+
+Overloaded to_api method to ensure item-level itypes is adhered to.
+
+=cut
+
+sub to_api {
+    my ($self, $params) = @_;
+
+    my $response = $self->SUPER::to_api($params);
+    my $overrides = {};
+
+    $overrides->{effective_item_type_id} = $self->effective_itemtype;
+    $overrides->{effective_not_for_loan_status} = $self->notforloan ? $self->notforloan : $self->itemtype->notforloan;
+
+    return { %$response, %$overrides };
+}
+
 =head3 to_api_mapping
 
 This method returns the mapping for representing a Koha::Item object
@@ -1381,7 +1510,8 @@ sub to_api_mapping {
         enumchron                => 'serial_issue_number',
         copynumber               => 'copy_number',
         stocknumber              => 'inventory_number',
-        new_status               => 'new_status'
+        new_status               => 'new_status',
+        deleted_on               => undef,
     };
 }
 
@@ -1395,6 +1525,7 @@ sub to_api_mapping {
 
 sub itemtype {
     my ( $self ) = @_;
+
     return Koha::ItemTypes->find( $self->effective_itemtype );
 }
 
@@ -1570,7 +1701,14 @@ Adds the bundle_item passed to this item
 =cut
 
 sub add_to_bundle {
-    my ( $self, $bundle_item ) = @_;
+    my ( $self, $bundle_item, $options ) = @_;
+
+    $options //= {};
+
+    Koha::Exceptions::Item::Bundle::IsBundle->throw()
+      if ( $self->itemnumber eq $bundle_item->itemnumber
+        || $bundle_item->is_bundle
+        || $self->in_bundle );
 
     my $schema = Koha::Database->new->schema;
 
@@ -1579,6 +1717,26 @@ sub add_to_bundle {
     try {
         $schema->txn_do(
             sub {
+                my $checkout = $bundle_item->checkout;
+                if ($checkout) {
+                    unless ($options->{force_checkin}) {
+                        Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
+                    }
+
+                    my $branchcode = C4::Context->userenv->{'branch'};
+                    my ($success) = C4::Circulation::AddReturn($bundle_item->barcode, $branchcode);
+                    unless ($success) {
+                        Koha::Exceptions::Checkin::FailedCheckin->throw();
+                    }
+                }
+
+                my $holds = $bundle_item->current_holds;
+                if ($holds->count) {
+                    unless ($options->{ignore_holds}) {
+                        Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
+                    }
+                }
+
                 $self->_result->add_to_item_bundles_hosts(
                     { item => $bundle_item->itemnumber } );
 
@@ -1588,9 +1746,8 @@ sub add_to_bundle {
     }
     catch {
 
-        # 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 fro DBIx::Error
+        # 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
         if ( ref($_) eq 'DBIx::Class::Exception' ) {
-            warn $_->{msg};
             if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
                 # FK constraints
                 # FIXME: MySQL error, if we support more DB engines we should implement this for each
@@ -1631,7 +1788,7 @@ sub add_to_bundle {
             $_->rethrow();
         }
         else {
-            $_;
+            $_->rethrow();
         }
     };
 }
@@ -1892,6 +2049,94 @@ sub is_notforloan {
     return $is_notforloan;
 }
 
+=head3 is_denied_renewal
+
+    my $is_denied_renewal = $item->is_denied_renewal;
+
+Determine whether or not this item can be renewed based on the
+rules set in the ItemsDeniedRenewal system preference.
+
+=cut
+
+sub is_denied_renewal {
+    my ( $self ) = @_;
+
+    my $denyingrules = Koha::Config::SysPrefs->find('ItemsDeniedRenewal')->get_yaml_pref_hash();
+    return 0 unless $denyingrules;
+    foreach my $field (keys %$denyingrules) {
+        my $val = $self->$field;
+        if( !defined $val) {
+            if ( any { !defined $_ }  @{$denyingrules->{$field}} ){
+                return 1;
+            }
+        } elsif (any { defined($_) && $val eq $_ } @{$denyingrules->{$field}}) {
+           # If the results matches the values in the syspref
+           # We return true if match found
+            return 1;
+        }
+    }
+    return 0;
+}
+
+=head3 strings_map
+
+Returns a map of column name to string representations including the string,
+the mapping type and the mapping category where appropriate.
+
+Currently handles authorised value mappings, library, callnumber and itemtype
+expansions.
+
+Accepts a param hashref where the 'public' key denotes whether we want the public
+or staff client strings.
+
+=cut
+
+sub strings_map {
+    my ( $self, $params ) = @_;
+
+    my $columns_info  = $self->_result->result_source->columns_info;
+    my $frameworkcode = $self->biblio->frameworkcode;
+    my $tagslib       = C4::Biblio::GetMarcStructure( 1, $frameworkcode );
+    my $mss           = C4::Biblio::GetMarcSubfieldStructure( $frameworkcode, { unsafe => 1 } );
+
+    my ( $itemtagfield, $itemtagsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
+
+    # Hardcoded known 'authorised_value' values mapped to API codes
+    my $code_to_type = {
+        branches  => 'library',
+        cn_source => 'call_number_source',
+        itemtypes => 'item_type',
+    };
+
+    # Handle not null and default values for integers and dates
+    my $strings = {};
+
+    foreach my $col ( keys %{$columns_info} ) {
+
+        # By now, we are done with known columns, now check the framework for mappings
+        my $field = $self->_result->result_source->name . '.' . $col;
+
+        # Check there's an entry in the MARC subfield structure for the field
+        if (   exists $mss->{$field}
+            && scalar @{ $mss->{$field} } > 0
+            && $mss->{$field}[0]->{authorised_value} )
+        {
+            my $subfield = $mss->{$field}[0];
+            my $code     = $subfield->{authorised_value};
+
+            my $str  = C4::Biblio::GetAuthorisedValueDesc( $itemtagfield, $subfield->{tagsubfield}, $self->$col, '', $tagslib, undef, $params->{public} );
+            my $type = exists $code_to_type->{$code} ? $code_to_type->{$code} : 'av';
+            $strings->{$col} = {
+                str  => $str,
+                type => $type,
+                ( $type eq 'av' ? ( category => $code ) : () ),
+            };
+        }
+    }
+
+    return $strings;
+}
+
 =head3 _type
 
 =cut