Bug 33146: Unit tests
[koha-ffzg.git] / Koha / Item.pm
index 2cefac5..0d29655 100644 (file)
@@ -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,27 @@ 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::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::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);
 
@@ -66,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
@@ -163,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);
             }
         }
 
@@ -180,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 );
@@ -198,10 +195,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 +226,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 +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(
           {
@@ -333,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);
 }
 
@@ -356,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
@@ -368,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
@@ -416,6 +415,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 +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
@@ -546,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
@@ -868,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;
@@ -899,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
@@ -1089,7 +1145,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 +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
@@ -1109,6 +1165,7 @@ sub _set_found_trigger {
             : undef,
         }
       );
+    my $lostreturn_policy = $lost_proc_return_policy->{lostreturn};
 
     if ( $lostreturn_policy ) {
 
@@ -1131,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 },
@@ -1147,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 =
@@ -1191,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',
@@ -1252,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;
 }
 
@@ -1273,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
@@ -1325,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,
     };
 }
 
@@ -1339,6 +1525,7 @@ sub to_api_mapping {
 
 sub itemtype {
     my ( $self ) = @_;
+
     return Koha::ItemTypes->find( $self->effective_itemtype );
 }
 
@@ -1441,6 +1628,189 @@ 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, $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;
+
+    my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue');
+
+    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 } );
+
+                $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 \(`(?<column>.*?)`\)/ ) {
+                    Koha::Exceptions::Object::FKConstraint->throw(
+                        error     => 'Broken FK constraint',
+                        broken_fk => $+{column}
+                    );
+                }
+            }
+            elsif (
+                $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ )
+            {
+                Koha::Exceptions::Object::DuplicateID->throw(
+                    error        => 'Duplicate ID',
+                    duplicate_id => $+{key}
+                );
+            }
+            elsif ( $_->{msg} =~
+/Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\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 {
+            $_->rethrow();
+        }
+    };
+}
+
+=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
@@ -1651,6 +2021,122 @@ sub check_recalls {
     return $recall;
 }
 
+=head3 is_notforloan
+
+    my $is_notforloan = $item->is_notforloan;
+
+Determine whether or not this item is "notforloan" based on
+the item's notforloan status or its item type
+
+=cut
+
+sub is_notforloan {
+    my ( $self ) = @_;
+    my $is_notforloan = 0;
+
+    if ( $self->notforloan ){
+        $is_notforloan = 1;
+    }
+    else {
+        my $itemtype = $self->itemtype;
+        if ($itemtype){
+            if ( $itemtype->notforloan ){
+                $is_notforloan = 1;
+            }
+        }
+    }
+
+    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