X-Git-Url: http://koha-dev.rot13.org:8081/gitweb/?a=blobdiff_plain;f=Koha%2FItem.pm;h=89d1a948c455a8d8869e67503e95d8c275a304c0;hb=2d25c2860c4b06a63048834fb99b2968f1fd57b1;hp=16d6f95bed13650ea12201c2547581a7884bdca6;hpb=3b0505247ac358067a14a8b2edba087f212daad5;p=koha-ffzg.git diff --git a/Koha/Item.pm b/Koha/Item.pm index 16d6f95bed..89d1a948c4 100644 --- a/Koha/Item.pm +++ b/Koha/Item.pm @@ -20,6 +20,7 @@ package Koha::Item; use Modern::Perl; use List::MoreUtils qw( any ); +use Try::Tiny qw( catch try ); use Koha::Database; use Koha::DateUtils qw( dt_from_string output_pref ); @@ -31,22 +32,25 @@ use C4::ClassSource qw( GetClassSort ); use C4::Log qw( logaction ); use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue; +use Koha::Biblio::ItemGroups; use Koha::Checkouts; use Koha::CirculationRules; use Koha::CoverImages; -use Koha::SearchEngine::Indexer; 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::Item::Attributes; use Koha::ItemTypes; +use Koha::Libraries; use Koha::Patrons; use Koha::Plugins; -use Koha::Libraries; +use Koha::Recalls; +use Koha::Result::Boolean; +use Koha::SearchEngine::Indexer; use Koha::StockRotationItem; use Koha::StockRotationRotas; use Koha::TrackedLinks; -use Koha::Result::Boolean; use base qw(Koha::Object); @@ -163,11 +167,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); } } @@ -180,8 +180,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 ); @@ -198,10 +197,6 @@ sub store { } - unless ( $self->dateaccessioned ) { - $self->dateaccessioned($today); - } - my $result = $self->SUPER::store; if ( $log_action && C4::Context->preference("CataloguingLog") ) { $action eq 'create' @@ -233,8 +228,14 @@ sub delete { # FIXME check the item has no current issues # i.e. raise the appropriate exception + # Get the item group so we can delete it later if it has no items left + my $item_group = C4::Context->preference('EnableItemGroups') ? $self->item_group : undef; + my $result = $self->SUPER::delete; + # Delete the item gorup if it has no items left + $item_group->delete if ( $item_group && $item_group->items->count == 0 ); + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" ) unless $params->{skip_record_index}; @@ -292,20 +293,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_item( $self ); # 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( { @@ -333,6 +333,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); } @@ -356,9 +357,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 @@ -368,9 +369,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 @@ -416,6 +417,58 @@ sub checkout { return Koha::Checkout->_new_from_dbic( $checkout_rs ); } +=head3 item_group + +my $item_group = $item->item_group; + +Return the item group for this item + +=cut + +sub item_group { + my ( $self ) = @_; + + my $item_group_item = $self->_result->item_group_item; + return unless $item_group_item; + + my $item_group_rs = $item_group_item->item_group; + return unless $item_group_rs; + + my $item_group = Koha::Biblio::ItemGroup->_new_from_dbic( $item_group_rs ); + return $item_group; +} + +=head3 return_claims + + my $return_claims = $item->return_claims; + +Return any return_claims associated with this item + +=cut + +sub return_claims { + my ( $self, $params, $attrs ) = @_; + my $claims_rs = $self->_result->return_claims->search($params, $attrs); + return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs ); +} + +=head3 return_claim + + my $return_claim = $item->return_claim; + +Returns the most recent unresolved return_claims associated with this item + +=cut + +sub return_claim { + my ($self) = @_; + my $claims_rs = + $self->_result->return_claims->search( { resolution => undef }, + { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single; + return unless $claims_rs; + return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs); +} + =head3 holds my $holds = $item->holds(); @@ -512,19 +565,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 @@ -546,17 +588,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 @@ -868,6 +906,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; @@ -899,7 +957,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 @@ -1089,7 +1147,7 @@ Internal function, not exported, called only by Koha::Item->store. sub _set_found_trigger { my ( $self, $pre_mod_item ) = @_; - ## If item was lost, it has now been found, reverse any list item charges if necessary. + # Reverse any lost item charges if necessary. my $no_refund_after_days = C4::Context->preference('NoRefundOnLostReturnedItemsAge'); if ($no_refund_after_days) { @@ -1101,7 +1159,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 @@ -1109,6 +1167,7 @@ sub _set_found_trigger { : undef, } ); + my $lostreturn_policy = $lost_proc_return_policy->{lostreturn}; if ( $lostreturn_policy ) { @@ -1131,14 +1190,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 }, @@ -1147,13 +1218,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 = @@ -1191,58 +1264,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 } - } - ); - } - - # Reconcile balances if required - if ( C4::Context->preference('AccountAutoReconcile') ) { - $account->reconcile_balance; - } + type => 'info', + message => 'lost_restored', + payload => { refund_id => $refund->id } + } + ); + } + + # 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', @@ -1252,6 +1323,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; } @@ -1273,6 +1442,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 @@ -1325,7 +1512,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, }; } @@ -1339,6 +1527,7 @@ sub to_api_mapping { sub itemtype { my ( $self ) = @_; + return Koha::ItemTypes->find( $self->effective_itemtype ); } @@ -1441,6 +1630,167 @@ sub move_to_biblio { return $to_biblionumber; } +=head3 bundle_items + + my $bundle_items = $item->bundle_items; + +Returns the items associated with this bundle + +=cut + +sub bundle_items { + my ($self) = @_; + + if ( !$self->{_bundle_items_cached} ) { + my $bundle_items = Koha::Items->search( + { 'item_bundles_item.host' => $self->itemnumber }, + { join => 'item_bundles_item' } ); + $self->{_bundle_items} = $bundle_items; + $self->{_bundle_items_cached} = 1; + } + + return $self->{_bundle_items}; +} + +=head3 is_bundle + + my $is_bundle = $item->is_bundle; + +Returns whether the item is a bundle or not + +=cut + +sub is_bundle { + my ($self) = @_; + return $self->bundle_items->count ? 1 : 0; +} + +=head3 bundle_host + + my $bundle = $item->bundle_host; + +Returns the bundle item this item is attached to + +=cut + +sub bundle_host { + my ($self) = @_; + + my $bundle_items_rs = $self->_result->item_bundles_item; + return unless $bundle_items_rs; + return Koha::Item->_new_from_dbic($bundle_items_rs->host); +} + +=head3 in_bundle + + my $in_bundle = $item->in_bundle; + +Returns whether this item is currently in a bundle + +=cut + +sub in_bundle { + my ($self) = @_; + return $self->bundle_host ? 1 : 0; +} + +=head3 add_to_bundle + + my $link = $item->add_to_bundle($bundle_item); + +Adds the bundle_item passed to this item + +=cut + +sub add_to_bundle { + my ( $self, $bundle_item ) = @_; + + 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; + + my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue'); + + try { + $schema->txn_do( + sub { + $self->_result->add_to_item_bundles_hosts( + { item => $bundle_item->itemnumber } ); + + $bundle_item->notforloan($BundleNotLoanValue)->store(); + } + ); + } + 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 from DBIx::Error + if ( ref($_) eq 'DBIx::Class::Exception' ) { + 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 + if ( $_->{msg} =~ /FOREIGN KEY \(`(?.*?)`\)/ ) { + Koha::Exceptions::Object::FKConstraint->throw( + error => 'Broken FK constraint', + broken_fk => $+{column} + ); + } + } + elsif ( + $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?.*?)'/ ) + { + Koha::Exceptions::Object::DuplicateID->throw( + error => 'Duplicate ID', + duplicate_id => $+{key} + ); + } + elsif ( $_->{msg} =~ +/Incorrect (?\w+) value: '(?.*)' for column \W?(?\S+)/ + ) + { # The optional \W in the regex might be a quote or backtick + my $type = $+{type}; + my $value = $+{value}; + my $property = $+{property}; + $property =~ s/['`]//g; + Koha::Exceptions::Object::BadValue->throw( + type => $type, + value => $value, + property => $property =~ /(\w+\.\w+)$/ + ? $1 + : $property + , # results in table.column without quotes or backtics + ); + } + + # Catch-all for foreign key breakages. It will help find other use cases + $_->rethrow(); + } + else { + $_; + } + }; +} + +=head3 remove_from_bundle + +Remove this item from any bundle it may have been attached to. + +=cut + +sub remove_from_bundle { + my ($self) = @_; + + my $bundle_item_rs = $self->_result->item_bundles_item; + if ( $bundle_item_rs ) { + $bundle_item_rs->delete; + $self->notforloan(0)->store(); + return 1; + } + return 0; +} + =head2 Internal methods =head3 _after_item_action_hooks @@ -1679,6 +2029,35 @@ 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 _type =cut