Bugzilla – Attachment 137503 Details for
Bug 28854
Add ability to create bundles of items for circulation
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 28854: Squashed Patch Set for Reading
Bug-28854-Squashed-Patch-Set-for-Reading.patch (text/plain), 116.45 KB, created by
Kyle M Hall (khall)
on 2022-07-11 11:17:30 UTC
(
hide
)
Description:
Bug 28854: Squashed Patch Set for Reading
Filename:
MIME Type:
Creator:
Kyle M Hall (khall)
Created:
2022-07-11 11:17:30 UTC
Size:
116.45 KB
patch
obsolete
>From a5b2f299b87e0e7d2d8ba136acb4ae7148190487 Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@ptfs-europe.com> >Date: Thu, 23 Sep 2021 08:28:15 +0100 >Subject: [PATCH] Bug 28854: Squashed Patch Set for Reading > >Bug 28854: Database update > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: DBIC Schema Updates > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Expose functionality to attach items to bundles > >This patch adds methods the the Koha::Item object for managing item >bundling operations and then exposes those methods via the REST API. > >We include the new `BundleNotLoanValue` preference for setting not >for loan values when an item is added to a bundle. > >Finally, we expose bundle management via the catalogue details page. > >Test plan: >0) Apply patches up to this point and run the database update >1) Configuration: `BundleNotLoanValue` should have been set by the > database update and point to a newly added AV value. >2) Creating a new bundle > * Add a new bib record > * Mark the bib record as a 'collection' type by setting leader > position 7 to 'c' > * Add a new item to this bib record > * You should see a new 'Manage bundle' button available in the > 'Actions' column of the Holdings table. > * Clicking 'Manage bundle' should expand the table to include a new > row directly beneath this one. > * Use the new 'Add to bundle' button that appears in this row to > trigger a modal that allows entering the barcode of items you wish > to add to the bundle > * Upon closing the modal, the bundle content table should reload and > contain your newly associated items. > * You can subsequently remove an item from a bundle using the new > 'Remove' button. >3) Not for loan > * Items that have been added into a bundle should now appear as 'Not > for loan' from their original biblio record and note which bundle > they belong to. >4) Error cases > * Try adding an item that already belongs to a bundle to another > bundle: Note an error is displayed in the modal form. >5) The bundles feature can be disabled by unsetting the > `BundleNotLoanValue` system preference. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Update circulation functionality for bundles > >This patch updates the circulation system to account for bundle >checkins. We add a content verification step to ensure bundle content is >all present at checkin and we use this comparison to mark missing items >as lost. > >Test plan >0) Apply patches up to this point >1) Checkin an item that belongs to a bundle > * An alert should be triggered noting that the item belongs to a > bundle > * The option to remove the item from the bundle should be clear > * Click remove should result in the alert dissapearing and the item > having been removed from the bundle. >2) Checkin an item bundle > * A modal confirmation dialog should appear requesting each item > barcode be scanned > * As items are scanned they should be highlighted in yellow in the > bundle content table > * Upon submission; > * The user will be alerted to any unexpected items that were > scanned and told to put them to one side. > * The user will be alerted that any missing items in the validation > will have been marked as lost. > * The bundle item will be marked as checked in. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Highlight bundle rows to clarify UI > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Drop unique index on issue_id in return claims > >With the introduction of circulating items within a bundle set, we can >now loose multiple items from the bundle from the same issue. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: DBIC Update ReturnClaim Schema > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Record and display who lost the item > >This patch records the bundle issue from which an item is marked as lost >so that we may use that to infer who lost the item (for later charges >and display). > >Test plan >0) Apply all patches up to this point >1) Checkout a bundle to a user >2) Checkin the bundle and do not scan one of the barcodes at > confirmation > * Note that the item not scanned is marked as lost >3) Navigate to the biblio for the lost item and note that it is marked > as lost. >4) Navigate to the biblio for the collection and expand the collection > item that contains the lost item. Note the item is marked as lost and > checkout details are listed. >5) Checkin the lost item > * The item should be marked as found and the return_claims line should > be marked as resolved. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Simplified status handling for bundle inventory check > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Add option to print content list after verification > >We already allowed the user to view and print an updated content list >after a varification showed that items were missing from the bundle. >This patch adds the option to view and print the list even if the >content has been varified to have not changed. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Add ordering to checkin validation modal > >This enhancement adds simple dataTable ordering to the verification >modal table at bundle checkin time. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Improve lost details display for bundle items > >This patch adds the return claim details to the bundle item status >display on the catalogue details page. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Enable claims return view when BundleLostValue is set > >This patch adds the alternation of BundleLostValue to enable the return >claims functionality when Bundles are enbled. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Highlight when a scanned item is unexpected > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Add count of scanned items to verification modal > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Add 'Remove from bundle' dialogue to details display > >This patch adds the counterpart of the 'Add to bundle' modal prompt for >adding items to bundles from the catalogue details display. It allows >for scanning barcodes to remove them from a bundle. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Add modal after checkin to print missing items list > >This patch adds a further modal to the post checkin alert to allow the >user to print a view and print a list of items that went missing at this >checkin to allow for replacements to be picked. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Only count rows in the body > >This patch fixes the count to only include table rows in the body, i.e. >skip the header row. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Make barcode comparison case insensative > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (QA follow-up) Fix QA tool complaints > >* Typo in code comment >* Permissions on atomicupdate >* Missing TT filter in template >* Spelling in template > >Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com> > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Use Koha::Item->itemtype introduced with bug 20469 > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Unit test for Koha::Item additions > >This patch adds unit tests for the new methods added to Koha::Item >including: > >* return_claim >* return_claims >* is_bundle >* in_bundle >* bundle_host >* bundle_items >* add_to_bundle >* remove_from_bundle > >Test plan >1) Run t/db_dependent/Koha/Item.t > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Unit test for AddReturn addition > >This patch adds tests for the AddReturn change that adds an 'InBundle' message >to the return. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Unit tests - chargelostitem > >This patch adds unit tests for the changes to chargelositem to ensure >bundle charging works as expected setting the correct issue_id. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: Add default AV and Syspref values > >This patch adds the new AV and System Preferences to the installer. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Translations fixes > >This patch wraps and unwraps strings appropriately for trasnlation. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Rename column configuration to barcode > >This patch updates the column configuration from 'external_id' to >'barcode' > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Move preferences to "Item bundles" > >Move the preferences into a section under 'Circulation' called 'Item >bundles' > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Fix typo in templates and test plan > >* aready > already >* 61 > 62 > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Improve error for item not found > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Use barcodedecode in Koha::REST::V1::Items > >This removes any leading or trailing whitespace from the external id >passed > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Disable bundle editing for checked out > >This patch disables the add and remove options under bundle management >when the bundled item in question is checked out. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Link bundle items to thier biblios > >This patch splits out the title formatting from the bundle item title >display into it's own new js-biblio-format include file and adds biblio >linking options. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Restore last seen date formatting > >We lost the date formatting somewhere along the line with rebases, this >patch restores the call to $date for last seen date. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Add bundle item status to manage button > >This patch adds the item counts (Present|Lost) to the 'Manage bundle' >button in the catalogue details page. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Make 'Manage bundle' button a 'toggle' > >This patch adds and removes the 'active' class to the 'Manage bundle' >button such that it correctly reflects whether the manage area is >expanded or not for the bundle item. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Restore block styling on bundled span > >This patch restores a css rule that we lost during rebases that makes >the 'bundled' span that appears for bundled items on their respective >details pages so that the 'In bundle: ' text in the status field of the >table appears beneath the 'Not for loan (Added to bundle)' text. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Link to 'In bundle' on OPAC > >This patch adds the 'In bundle: Link' to the OPAC to mirror what we have >on the staff client. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Add handling for part_numbers/names > >This patch adds part number/name handling to the new biblio-format js >include. This will add such details to bundle management tables. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (QA follow-up) Add missing TT filters > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> > >Bug 28854: (follow-up) Remove fields from from bundle items > >This patch removes the itemtype and collection code fields from the >bundle items table. > >We will add the itemtype back in at a later date with a new bug as the >display code needs some careful consideration and we felt that doing >that work inline here would be an unrequired blocker. > >Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de> >--- > C4/Accounts.pm | 7 +- > C4/Circulation.pm | 6 + > Koha/Checkouts/ReturnClaims.pm | 2 +- > Koha/Item.pm | 191 ++++++++- > Koha/REST/V1/Items.pm | 124 ++++++ > Koha/Schema/Result/Item.pm | 30 ++ > Koha/Schema/Result/ItemBundle.pm | 113 ++++++ > Koha/Schema/Result/ReturnClaim.pm | 18 +- > admin/columns_settings.yml | 18 + > api/v1/swagger/definitions/bundle_link.yaml | 14 + > api/v1/swagger/definitions/item.yaml | 9 + > api/v1/swagger/paths/items.yaml | 160 ++++++++ > api/v1/swagger/swagger.yaml | 6 + > catalogue/detail.pl | 18 + > circ/returns.pl | 98 ++++- > .../data/mysql/atomicupdate/bug_28854.pl | 55 +++ > installer/data/mysql/en/optional/auth_val.yml | 8 + > installer/data/mysql/kohastructure.sql | 18 +- > installer/data/mysql/mandatory/sysprefs.sql | 2 + > .../intranet-tmpl/prog/css/src/_tables.scss | 2 +- > .../prog/css/src/staff-global.scss | 14 + > .../prog/en/includes/html_helpers.inc | 2 + > .../prog/en/includes/js-biblio-format.inc | 64 +++ > .../en/includes/modals/bundle_contents.inc | 35 ++ > .../admin/preferences/circulation.pref | 11 + > .../prog/en/modules/catalogue/detail.tt | 370 +++++++++++++++++- > .../prog/en/modules/circ/circulation.tt | 8 +- > .../prog/en/modules/circ/returns.tt | 242 ++++++++++++ > .../prog/en/modules/members/moremember.tt | 2 +- > .../bootstrap/en/includes/item-status.inc | 4 + > opac/opac-detail.pl | 5 + > t/db_dependent/Accounts.t | 16 +- > t/db_dependent/Circulation.t | 19 + > t/db_dependent/Koha/Item.t | 195 ++++++++- > 34 files changed, 1854 insertions(+), 32 deletions(-) > create mode 100644 Koha/Schema/Result/ItemBundle.pm > create mode 100644 api/v1/swagger/definitions/bundle_link.yaml > create mode 100755 installer/data/mysql/atomicupdate/bug_28854.pl > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/js-biblio-format.inc > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc > >diff --git a/C4/Accounts.pm b/C4/Accounts.pm >index ad0d34f184..e5b7c441d8 100644 >--- a/C4/Accounts.pm >+++ b/C4/Accounts.pm >@@ -70,7 +70,8 @@ FIXME : if no replacement price, borrower just doesn't get charged? > sub chargelostitem { > my $dbh = C4::Context->dbh(); > my ($borrowernumber, $itemnumber, $amount, $description) = @_; >- my $itype = Koha::ItemTypes->find({ itemtype => Koha::Items->find($itemnumber)->effective_itemtype() }); >+ my $item = Koha::Items->find($itemnumber); >+ my $itype = $item->itemtype; > my $replacementprice = $amount; > my $defaultreplacecost = $itype->defaultreplacecost; > my $processfee = $itype->processfee; >@@ -80,6 +81,10 @@ sub chargelostitem { > $replacementprice = $defaultreplacecost; > } > my $checkout = Koha::Checkouts->find({ itemnumber => $itemnumber }); >+ if ( !$checkout && $item->in_bundle ) { >+ my $host = $item->bundle_host; >+ $checkout = $host->checkout; >+ } > my $issue_id = $checkout ? $checkout->issue_id : undef; > > my $account = Koha::Account->new({ patron_id => $borrowernumber }); >diff --git a/C4/Circulation.pm b/C4/Circulation.pm >index a10503385f..a18864dea7 100644 >--- a/C4/Circulation.pm >+++ b/C4/Circulation.pm >@@ -2433,6 +2433,12 @@ sub AddReturn { > } > } > >+ # Check for bundle status >+ if ( $item->in_bundle ) { >+ my $host = $item->bundle_host; >+ $messages->{InBundle} = $host; >+ } >+ > my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); > $indexer->index_records( $item->biblionumber, "specialUpdate", "biblioserver" ); > >diff --git a/Koha/Checkouts/ReturnClaims.pm b/Koha/Checkouts/ReturnClaims.pm >index c1c2b3bd44..1174cdd1a2 100644 >--- a/Koha/Checkouts/ReturnClaims.pm >+++ b/Koha/Checkouts/ReturnClaims.pm >@@ -60,7 +60,7 @@ sub resolved { > return Koha::Checkouts::ReturnClaims->_new_from_dbic( $results ); > } > >-=head3 type >+=head3 _type > > =cut > >diff --git a/Koha/Item.pm b/Koha/Item.pm >index 38b266655b..fecdf5ddcf 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 ); >@@ -440,6 +441,37 @@ sub item_group { > 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(); >@@ -1113,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) { >@@ -1465,6 +1497,163 @@ 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 ) = @_; >+ >+ 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' ) { >+ 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 >+ 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 { >+ $_; >+ } >+ }; >+} >+ >+=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 >diff --git a/Koha/REST/V1/Items.pm b/Koha/REST/V1/Items.pm >index d3c5d7e177..88a0675257 100644 >--- a/Koha/REST/V1/Items.pm >+++ b/Koha/REST/V1/Items.pm >@@ -19,6 +19,8 @@ use Modern::Perl; > > use Mojo::Base 'Mojolicious::Controller'; > >+use C4::Circulation qw( barcodedecode ); >+ > use Koha::Items; > > use List::MoreUtils qw( any ); >@@ -148,4 +150,126 @@ sub pickup_locations { > }; > } > >+=head3 bundled_items >+ >+Controller function that handles bundled_items Koha::Item objects >+ >+=cut >+ >+sub bundled_items { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $item_id = $c->validation->param('item_id'); >+ my $item = Koha::Items->find( $item_id ); >+ >+ unless ($item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Item not found" } >+ ); >+ } >+ >+ return try { >+ my $items_set = $item->bundle_items; >+ my $items = $c->objects->search( $items_set ); >+ return $c->render( >+ status => 200, >+ openapi => $items >+ ); >+ } >+ catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+=head3 add_to_bundle >+ >+Controller function that handles adding items to this bundle >+ >+=cut >+ >+sub add_to_bundle { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $item_id = $c->validation->param('item_id'); >+ my $item = Koha::Items->find( $item_id ); >+ >+ unless ($item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Item not found" } >+ ); >+ } >+ >+ my $bundle_item_id = $c->validation->param('body')->{'external_id'}; >+ $bundle_item_id = barcodedecode($bundle_item_id); >+ my $bundle_item = Koha::Items->find( { barcode => $bundle_item_id } ); >+ >+ unless ($bundle_item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Bundle item not found" } >+ ); >+ } >+ >+ return try { >+ my $link = $item->add_to_bundle($bundle_item); >+ return $c->render( >+ status => 201, >+ openapi => $bundle_item >+ ); >+ } >+ catch { >+ if ( ref($_) eq 'Koha::Exceptions::Object::DuplicateID' ) { >+ return $c->render( >+ status => 409, >+ openapi => { >+ error => 'Item is already bundled', >+ key => $_->duplicate_id >+ } >+ ); >+ } >+ else { >+ $c->unhandled_exception($_); >+ } >+ }; >+} >+ >+=head3 remove_from_bundle >+ >+Controller function that handles removing items from this bundle >+ >+=cut >+ >+sub remove_from_bundle { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $item_id = $c->validation->param('item_id'); >+ my $item = Koha::Items->find( $item_id ); >+ >+ unless ($item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Item not found" } >+ ); >+ } >+ >+ my $bundle_item_id = $c->validation->param('bundled_item_id'); >+ $bundle_item_id = barcodedecode($bundle_item_id); >+ my $bundle_item = Koha::Items->find( { itemnumber => $bundle_item_id } ); >+ >+ unless ($bundle_item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Bundle item not found" } >+ ); >+ } >+ >+ $bundle_item->remove_from_bundle; >+ return $c->render( >+ status => 204, >+ openapi => q{} >+ ); >+} >+ > 1; >diff --git a/Koha/Schema/Result/Item.pm b/Koha/Schema/Result/Item.pm >index 01ebbe3636..55a1235096 100644 >--- a/Koha/Schema/Result/Item.pm >+++ b/Koha/Schema/Result/Item.pm >@@ -744,6 +744,36 @@ __PACKAGE__->might_have( > { cascade_copy => 0, cascade_delete => 0 }, > ); > >+=head2 item_bundles_hosts >+ >+Type: has_many >+ >+Related object: L<Koha::Schema::Result::ItemBundle> >+ >+=cut >+ >+__PACKAGE__->has_many( >+ "item_bundles_hosts", >+ "Koha::Schema::Result::ItemBundle", >+ { "foreign.host" => "self.itemnumber" }, >+ { cascade_copy => 0, cascade_delete => 0 }, >+); >+ >+=head2 item_bundles_item >+ >+Type: might_have >+ >+Related object: L<Koha::Schema::Result::ItemBundle> >+ >+=cut >+ >+__PACKAGE__->might_have( >+ "item_bundles_item", >+ "Koha::Schema::Result::ItemBundle", >+ { "foreign.item" => "self.itemnumber" }, >+ { cascade_copy => 0, cascade_delete => 0 }, >+); >+ > =head2 items_last_borrower > > Type: might_have >diff --git a/Koha/Schema/Result/ItemBundle.pm b/Koha/Schema/Result/ItemBundle.pm >new file mode 100644 >index 0000000000..4f89ee3294 >--- /dev/null >+++ b/Koha/Schema/Result/ItemBundle.pm >@@ -0,0 +1,113 @@ >+use utf8; >+package Koha::Schema::Result::ItemBundle; >+ >+# Created by DBIx::Class::Schema::Loader >+# DO NOT MODIFY THE FIRST PART OF THIS FILE >+ >+=head1 NAME >+ >+Koha::Schema::Result::ItemBundle >+ >+=cut >+ >+use strict; >+use warnings; >+ >+use base 'DBIx::Class::Core'; >+ >+=head1 TABLE: C<item_bundles> >+ >+=cut >+ >+__PACKAGE__->table("item_bundles"); >+ >+=head1 ACCESSORS >+ >+=head2 item >+ >+ data_type: 'integer' >+ is_foreign_key: 1 >+ is_nullable: 0 >+ >+=head2 host >+ >+ data_type: 'integer' >+ is_foreign_key: 1 >+ is_nullable: 0 >+ >+=cut >+ >+__PACKAGE__->add_columns( >+ "item", >+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, >+ "host", >+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, >+); >+ >+=head1 PRIMARY KEY >+ >+=over 4 >+ >+=item * L</host> >+ >+=item * L</item> >+ >+=back >+ >+=cut >+ >+__PACKAGE__->set_primary_key("host", "item"); >+ >+=head1 UNIQUE CONSTRAINTS >+ >+=head2 C<item_bundles_uniq_1> >+ >+=over 4 >+ >+=item * L</item> >+ >+=back >+ >+=cut >+ >+__PACKAGE__->add_unique_constraint("item_bundles_uniq_1", ["item"]); >+ >+=head1 RELATIONS >+ >+=head2 host >+ >+Type: belongs_to >+ >+Related object: L<Koha::Schema::Result::Item> >+ >+=cut >+ >+__PACKAGE__->belongs_to( >+ "host", >+ "Koha::Schema::Result::Item", >+ { itemnumber => "host" }, >+ { is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" }, >+); >+ >+=head2 item >+ >+Type: belongs_to >+ >+Related object: L<Koha::Schema::Result::Item> >+ >+=cut >+ >+__PACKAGE__->belongs_to( >+ "item", >+ "Koha::Schema::Result::Item", >+ { itemnumber => "item" }, >+ { is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" }, >+); >+ >+ >+# Created by DBIx::Class::Schema::Loader v0.07049 @ 2022-03-31 13:56:51 >+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:T02Qk/Ojl7OXlL62uQk6Og >+ >+ >+# You can replace this text with custom code or comments, and it will be preserved on regeneration >+1; >diff --git a/Koha/Schema/Result/ReturnClaim.pm b/Koha/Schema/Result/ReturnClaim.pm >index 9e67c7f0e7..6256e5da1f 100644 >--- a/Koha/Schema/Result/ReturnClaim.pm >+++ b/Koha/Schema/Result/ReturnClaim.pm >@@ -170,20 +170,6 @@ __PACKAGE__->add_columns( > > __PACKAGE__->set_primary_key("id"); > >-=head1 UNIQUE CONSTRAINTS >- >-=head2 C<issue_id> >- >-=over 4 >- >-=item * L</issue_id> >- >-=back >- >-=cut >- >-__PACKAGE__->add_unique_constraint("issue_id", ["issue_id"]); >- > =head1 RELATIONS > > =head2 borrowernumber >@@ -277,8 +263,8 @@ __PACKAGE__->belongs_to( > ); > > >-# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-11-17 10:01:24 >-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Ik93SD3kLNecIyRgsBVKDQ >+# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-11-18 15:07:03 >+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:HtOvm4y611GNQcPwKzY1jg > > =head2 checkout > >diff --git a/admin/columns_settings.yml b/admin/columns_settings.yml >index 7e68c4d0b5..3f146593e6 100644 >--- a/admin/columns_settings.yml >+++ b/admin/columns_settings.yml >@@ -525,6 +525,24 @@ modules: > - > columnname: checkin_on > >+ bundle_tables: >+ columns: >+ - >+ columnname: title >+ cannot_be_toggled: 1 >+ - >+ columnname: author >+ - >+ columnname: callnumber >+ - >+ columnname: barcode >+ - >+ columnname: status >+ - >+ columnname: bundle_actions >+ cannot_be_toggled: 1 >+ cannot_be_modified: 1 >+ > cataloguing: > addbooks: > reservoir-table: >diff --git a/api/v1/swagger/definitions/bundle_link.yaml b/api/v1/swagger/definitions/bundle_link.yaml >new file mode 100644 >index 0000000000..572be83d8e >--- /dev/null >+++ b/api/v1/swagger/definitions/bundle_link.yaml >@@ -0,0 +1,14 @@ >+--- >+type: object >+properties: >+ item_id: >+ type: >+ - integer >+ - "null" >+ description: Internal item identifier >+ external_id: >+ type: >+ - string >+ - "null" >+ description: Item barcode >+additionalProperties: false >diff --git a/api/v1/swagger/definitions/item.yaml b/api/v1/swagger/definitions/item.yaml >index 8b00c96c88..22df7d4aad 100644 >--- a/api/v1/swagger/definitions/item.yaml >+++ b/api/v1/swagger/definitions/item.yaml >@@ -7,6 +7,7 @@ properties: > biblio_id: > type: integer > description: Internal identifier for the parent bibliographic record >+ biblio: {} > external_id: > type: > - string >@@ -211,6 +212,14 @@ properties: > exclude_from_local_holds_priority: > type: boolean > description: Exclude this item from local holds priority. >+ return_claims: >+ type: array >+ description: An array of all return claims associated with this item >+ return_claim: >+ type: >+ - object >+ - "null" >+ description: A return claims object if one exists that's unresolved > additionalProperties: false > required: > - item_id >diff --git a/api/v1/swagger/paths/items.yaml b/api/v1/swagger/paths/items.yaml >index 66fc1ee6a8..7c947f6e3f 100644 >--- a/api/v1/swagger/paths/items.yaml >+++ b/api/v1/swagger/paths/items.yaml >@@ -93,6 +93,166 @@ > x-koha-authorization: > permissions: > catalogue: "1" >+"/items/{item_id}/bundled_items": >+ post: >+ x-mojo-to: Items#add_to_bundle >+ operationId: addToBundle >+ tags: >+ - items >+ summary: Add item to bundle >+ parameters: >+ - $ref: "../swagger.yaml#/parameters/item_id_pp" >+ - name: body >+ in: body >+ description: A JSON object containing information about the new bundle link >+ required: true >+ schema: >+ $ref: "../swagger.yaml#/definitions/bundle_link" >+ consumes: >+ - application/json >+ produces: >+ - application/json >+ responses: >+ "201": >+ description: A successfully created bundle link >+ schema: >+ items: >+ $ref: "../swagger.yaml#/definitions/item" >+ "400": >+ description: Bad parameter >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Authentication required >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Access forbidden >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "404": >+ description: Resource not found >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "409": >+ description: Conflict in creating resource >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: Internal server error >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ catalogue: 1 >+ get: >+ x-mojo-to: Items#bundled_items >+ operationId: bundledItems >+ tags: >+ - items >+ summary: List bundled items >+ parameters: >+ - $ref: "../swagger.yaml#/parameters/item_id_pp" >+ - name: external_id >+ in: query >+ description: Search on the item's barcode >+ required: false >+ type: string >+ - $ref: "../swagger.yaml#/parameters/match" >+ - $ref: "../swagger.yaml#/parameters/order_by" >+ - $ref: "../swagger.yaml#/parameters/page" >+ - $ref: "../swagger.yaml#/parameters/per_page" >+ - $ref: "../swagger.yaml#/parameters/q_param" >+ - $ref: "../swagger.yaml#/parameters/q_body" >+ - $ref: "../swagger.yaml#/parameters/q_header" >+ consumes: >+ - application/json >+ produces: >+ - application/json >+ responses: >+ "200": >+ description: A list of item >+ schema: >+ type: array >+ items: >+ $ref: "../swagger.yaml#/definitions/item" >+ "401": >+ description: Authentication required >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Access forbidden >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: Internal server error >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ catalogue: "1" >+ x-koha-embed: >+ - biblio >+ - checkout >+ - return_claims >+ - return_claim >+ - return_claim.patron >+"/items/{item_id}/bundled_items/{bundled_item_id}": >+ delete: >+ x-mojo-to: Items#remove_from_bundle >+ operationId: removeFromBundle >+ tags: >+ - items >+ summary: Remove item from bundle >+ parameters: >+ - $ref: "../swagger.yaml#/parameters/item_id_pp" >+ - name: bundled_item_id >+ in: path >+ description: Internal identifier for the bundled item >+ required: true >+ type: string >+ consumes: >+ - application/json >+ produces: >+ - application/json >+ responses: >+ "204": >+ description: Bundle link deleted >+ "400": >+ description: Bad parameter >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Authentication required >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Access forbidden >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "404": >+ description: Resource not found >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: Internal server error >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ catalogue: 1 > "/items/{item_id}/pickup_locations": > get: > x-mojo-to: Items#pickup_locations >diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml >index dbb0c20881..7e3a8d8afc 100644 >--- a/api/v1/swagger/swagger.yaml >+++ b/api/v1/swagger/swagger.yaml >@@ -10,6 +10,8 @@ definitions: > $ref: ./definitions/allows_renewal.yaml > basket: > $ref: ./definitions/basket.yaml >+ bundle_link: >+ $ref: ./definitions/bundle_link.yaml > cashup: > $ref: ./definitions/cashup.yaml > checkout: >@@ -169,6 +171,10 @@ paths: > $ref: ./paths/items.yaml#/~1items > "/items/{item_id}": > $ref: "./paths/items.yaml#/~1items~1{item_id}" >+ "/items/{item_id}/bundled_items": >+ $ref: ./paths/items.yaml#/~1items~1{item_id}~1bundled_items >+ "/items/{item_id}/bundled_items/{bundled_item_id}": >+ $ref: ./paths/items.yaml#/~1items~1{item_id}~1bundled_items~1{bundled_item_id} > "/items/{item_id}/pickup_locations": > $ref: "./paths/items.yaml#/~1items~1{item_id}~1pickup_locations" > /libraries: >diff --git a/catalogue/detail.pl b/catalogue/detail.pl >index 34447566b5..22a60909cb 100755 >--- a/catalogue/detail.pl >+++ b/catalogue/detail.pl >@@ -206,6 +206,11 @@ if (@hostitems){ > > my $dat = &GetBiblioData($biblionumber); > >+#is biblio a collection and are bundles enabled >+my $leader = $record->leader(); >+$dat->{bundlesEnabled} = ( ( substr( $leader, 7, 1 ) eq 'c' ) >+ && C4::Context->preference('BundleNotLoanValue') ) ? 1 : 0; >+ > #coping with subscriptions > my $subscriptionsnumber = CountSubscriptionFromBiblionumber($biblionumber); > my @subscriptions = SearchSubscriptions({ biblionumber => $biblionumber, orderby => 'title' }); >@@ -451,6 +456,19 @@ foreach my $item (@items) { > } > } > >+ if ( $item_object->is_bundle ) { >+ $item->{bundled} = >+ $item_object->bundle_items->search( { itemlost => { '!=' => 0 } } ) >+ ->count; >+ $item->{bundled_lost} = >+ $item_object->bundle_items->search( { itemlost => 0 } )->count; >+ $item->{is_bundle} = 1; >+ } >+ >+ if ($item_object->in_bundle) { >+ $item->{bundle_host} = $item_object->bundle_host; >+ } >+ > if ($currentbranch and C4::Context->preference('SeparateHoldings')) { > if ($itembranchcode and $itembranchcode eq $currentbranch) { > push @itemloop, $item; >diff --git a/circ/returns.pl b/circ/returns.pl >index 340efb4779..7399568cf5 100755 >--- a/circ/returns.pl >+++ b/circ/returns.pl >@@ -338,10 +338,18 @@ if ($barcode) { > $template->param( 'multiple_confirmed' => 1 ) > if $query->param('multiple_confirm'); > >+ # Block return if bundle and confirm has not been received >+ my $bundle_confirm = >+ $item >+ && $item->is_bundle >+ && !$query->param('confirm_items_bundle_return'); >+ $template->param( 'confirm_items_bundle_returned' => 1 ) >+ if $query->param('confirm_items_bundle_return'); >+ > # do the return > ( $returned, $messages, $issue, $borrower ) = > AddReturn( $barcode, $userenv_branch, $exemptfine, $return_date ) >- unless $needs_confirm; >+ unless ( $needs_confirm || $bundle_confirm ); > > if ($returned) { > my $time_now = dt_from_string()->truncate( to => 'minute'); >@@ -382,7 +390,8 @@ if ($barcode) { > ); > } > } >- } elsif ( C4::Context->preference('ShowAllCheckins') and !$messages->{'BadBarcode'} and !$needs_confirm ) { >+ >+ } elsif ( C4::Context->preference('ShowAllCheckins') and !$messages->{'BadBarcode'} and !$needs_confirm and !$bundle_confirm ) { > $input{duedate} = 0; > $returneditems{0} = $barcode; > $riduedate{0} = 0; >@@ -393,6 +402,89 @@ if ($barcode) { > if ( $needs_confirm ) { > $template->param( needs_confirm => $needs_confirm ); > } >+ >+ if ( $bundle_confirm ) { >+ $template->param( >+ items_bundle_return_confirmation => 1, >+ ); >+ } >+ >+ # Mark missing bundle items as lost and report unexpected items >+ if ( $item->is_bundle && $query->param('confirm_items_bundle_return') ) { >+ my $BundleLostValue = C4::Context->preference('BundleLostValue'); >+ my $barcodes = $query->param('verify-items-bundle-contents-barcodes'); >+ my @barcodes = map { s/^\s+|\s+$//gr } ( split /\n/, $barcodes ); >+ my $expected_items = { map { $_->barcode => $_ } $item->bundle_items->as_list }; >+ my $verify_items = Koha::Items->search( { barcode => { 'in' => \@barcodes } } ); >+ my @unexpected_items; >+ my @missing_items; >+ my @bundle_items; >+ while ( my $verify_item = $verify_items->next ) { >+ # Fix and lost statuses >+ $verify_item->itemlost(0); >+ >+ # Update last_seen >+ $verify_item->datelastseen( dt_from_string()->ymd() ); >+ >+ # Update last_borrowed if actual checkin >+ $verify_item->datelastborrowed( dt_from_string()->ymd() ) if $issue; >+ >+ # Expected item, remove from lookup table >+ if ( delete $expected_items->{$verify_item->barcode} ) { >+ push @bundle_items, $verify_item; >+ } >+ # Unexpected item, warn and remove from bundle >+ else { >+ $verify_item->remove_from_bundle; >+ push @unexpected_items, $verify_item; >+ } >+ >+ # Store results >+ $verify_item->store(); >+ } >+ for my $missing_item ( keys %{$expected_items} ) { >+ my $bundle_item = $expected_items->{$missing_item}; >+ # Mark as lost if it's not already lost >+ if ( !$bundle_item->itemlost ) { >+ $bundle_item->itemlost($BundleLostValue)->store(); >+ >+ # Add return_claim record if this is an actual checkin >+ if ($issue) { >+ $bundle_item->_result->create_related( >+ 'return_claims', >+ { >+ issue_id => $issue->issue_id, >+ itemnumber => $bundle_item->itemnumber, >+ borrowernumber => $issue->borrowernumber, >+ created_by => C4::Context->userenv()->{number}, >+ created_on => dt_from_string >+ } >+ ); >+ } >+ push @missing_items, $bundle_item; >+ >+ # NOTE: We cannot use C4::LostItem here because the item itself doesn't have a checkout >+ # and thus would not get charged.. it's checked out as part of the bundle. >+ if ( C4::Context->preference('WhenLostChargeReplacementFee') && $issue ) { >+ C4::Accounts::chargelostitem( >+ $issue->borrowernumber, >+ $bundle_item->itemnumber, >+ $bundle_item->replacementprice, >+ sprintf( "%s %s %s", >+ $bundle_item->biblio->title || q{}, >+ $bundle_item->barcode || q{}, >+ $bundle_item->itemcallnumber || q{}, >+ ), >+ ); >+ } >+ } >+ } >+ $template->param( >+ unexpected_items => \@unexpected_items, >+ missing_items => \@missing_items, >+ bundle_items => \@bundle_items >+ ); >+ } > } > $template->param( inputloop => \@inputloop ); > >@@ -631,6 +723,8 @@ foreach my $code ( keys %$messages ) { > ; > } elsif ( $code eq 'TransferredRecall' ) { > ; >+ } elsif ( $code eq 'InBundle' ) { >+ $template->param( InBundle => $messages->{InBundle} ); > } else { > die "Unknown error code $code"; # note we need all the (empty) elsif's above, or we die. > # This forces the issue of staying in sync w/ Circulation.pm >diff --git a/installer/data/mysql/atomicupdate/bug_28854.pl b/installer/data/mysql/atomicupdate/bug_28854.pl >new file mode 100755 >index 0000000000..d93fa9f49e >--- /dev/null >+++ b/installer/data/mysql/atomicupdate/bug_28854.pl >@@ -0,0 +1,55 @@ >+use Modern::Perl; >+ >+return { >+ bug_number => "28854", >+ description => "Item bundles support", >+ up => sub { >+ my ($args) = @_; >+ my ($dbh, $out) = @$args{qw(dbh out)}; >+ >+ if( !TableExists( 'item_bundles' ) ) { >+ $dbh->do(q{ >+ CREATE TABLE `item_bundles` ( >+ `item` int(11) NOT NULL, >+ `host` int(11) NOT NULL, >+ PRIMARY KEY (`host`, `item`), >+ UNIQUE KEY `item_bundles_uniq_1` (`item`), >+ CONSTRAINT `item_bundles_ibfk_1` FOREIGN KEY (`item`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE, >+ CONSTRAINT `item_bundles_ibfk_2` FOREIGN KEY (`host`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE >+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci >+ }); >+ } >+ say $out "item_bundles table added"; >+ >+ my ($lost_val) = $dbh->selectrow_array( "SELECT MAX(authorised_value) FROM authorised_values WHERE category = 'LOST'", {} ); >+ $lost_val++; >+ >+ $dbh->do(qq{ >+ INSERT INTO authorised_values (category,authorised_value,lib) VALUES ('LOST',$lost_val,'Missing from bundle') >+ }); >+ say $out "Missing from bundle LOST AV added"; >+ >+ my ($nfl_val) = $dbh->selectrow_array( "SELECT MAX(authorised_value) FROM authorised_values WHERE category = 'NOT_LOAN'", {} ); >+ $nfl_val++; >+ >+ $dbh->do(qq{ >+ INSERT INTO authorised_values (category,authorised_value,lib) VALUES ('NOT_LOAN',$nfl_val,'Added to bundle') >+ }); >+ say $out "Added to bundle NOT_LOAN AV added"; >+ >+ $dbh->do(qq{ >+ INSERT IGNORE INTO systempreferences( `variable`, `value`, `options`, `explanation`, `type` ) >+ VALUES >+ ( 'BundleLostValue', $lost_val, '', 'Sets the LOST AV value that represents "Missing from bundle" as a lost value', 'Free' ), >+ ( 'BundleNotLoanValue', $nfl_val, '', 'Sets the NOT_LOAN AV value that represents "Added to bundle" as a not for loan value', 'Free') >+ }); >+ say $out "System preferences added and set"; >+ >+ if( index_exists( 'return_claims', 'issue_id' ) ) { >+ $dbh->do(q{ >+ ALTER TABLE return_claims DROP INDEX issue_id >+ }); >+ say $out "Dropped unique constraint on issue_id in return_claims"; >+ } >+ } >+} >diff --git a/installer/data/mysql/en/optional/auth_val.yml b/installer/data/mysql/en/optional/auth_val.yml >index b680987a00..41c1cec9e6 100644 >--- a/installer/data/mysql/en/optional/auth_val.yml >+++ b/installer/data/mysql/en/optional/auth_val.yml >@@ -106,6 +106,10 @@ tables: > authorised_value: "4" > lib : "Missing" > >+ - category: "LOST" >+ authorised_value: "5" >+ lib : "Missing from bundle" >+ > # damaged status of an item > - category: "DAMAGED" > authorised_value: "1" >@@ -183,6 +187,10 @@ tables: > authorised_value: "2" > lib: "Staff Collection" > >+ - category: "NOT_LOAN" >+ authorised_value: "3" >+ lib: "Added to bundle" >+ > # restricted status of an item,linked to items.restricted > - category: "RESTRICTED" > authorised_value: "1" >diff --git a/installer/data/mysql/kohastructure.sql b/installer/data/mysql/kohastructure.sql >index 54676b54e7..3227ab27f1 100644 >--- a/installer/data/mysql/kohastructure.sql >+++ b/installer/data/mysql/kohastructure.sql >@@ -3173,6 +3173,23 @@ CREATE TABLE `items` ( > ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; > /*!40101 SET character_set_client = @saved_cs_client */; > >+-- >+-- Table structure for table item_bundles >+-- >+ >+DROP TABLE IF EXISTS `item_bundles`; >+/*!40101 SET @saved_cs_client = @@character_set_client */; >+/*!40101 SET character_set_client = utf8 */; >+CREATE TABLE `item_bundles` ( >+ `item` int(11) NOT NULL, >+ `host` int(11) NOT NULL, >+ PRIMARY KEY (`host`, `item`), >+ UNIQUE KEY `item_bundles_uniq_1` (`item`), >+ CONSTRAINT `item_bundles_ibfk_1` FOREIGN KEY (`item`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE, >+ CONSTRAINT `item_bundles_ibfk_2` FOREIGN KEY (`host`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE >+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; >+/*!40101 SET character_set_client = @saved_cs_client */; >+ > -- > -- Table structure for table `items_last_borrower` > -- >@@ -4497,7 +4514,6 @@ CREATE TABLE `return_claims` ( > `resolved_on` timestamp NULL DEFAULT NULL COMMENT 'Time and date the claim was resolved', > `resolved_by` int(11) DEFAULT NULL COMMENT 'ID of the staff member that resolved the claim', > PRIMARY KEY (`id`), >- UNIQUE KEY `issue_id` (`issue_id`), > KEY `itemnumber` (`itemnumber`), > KEY `rc_borrowers_ibfk` (`borrowernumber`), > KEY `rc_created_by_ibfk` (`created_by`), >diff --git a/installer/data/mysql/mandatory/sysprefs.sql b/installer/data/mysql/mandatory/sysprefs.sql >index 108007b3df..04d36ff9e1 100644 >--- a/installer/data/mysql/mandatory/sysprefs.sql >+++ b/installer/data/mysql/mandatory/sysprefs.sql >@@ -115,6 +115,8 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` > ('BorrowerUnwantedField','',NULL,'Name the fields you don\'t need to store for a patron\'s account','free'), > ('BranchTransferLimitsType','ccode','itemtype|ccode','When using branch transfer limits, choose whether to limit by itemtype or collection code.','Choice'), > ('BrowseResultSelection','0',NULL,'Enable/Disable browsing search results fromt the bibliographic record detail page in staff interface','YesNo'), >+('BundleLostValue','5',NULL,'Sets the LOST AV value that represents "Missing from bundle" as a lost value','Free'), >+('BundleNotLoanValue','3',NULL,'Sets the NOT_LOAN AV value that represents "Added to bundle" as a not for loan value','Free'), > ('CalculateFinesOnReturn','1','','Switch to control if overdue fines are calculated on return or not','YesNo'), > ('CalculateFinesOnBackdate','1','','Switch to control if overdue fines are calculated on return when backdating','YesNo'), > ('CalendarFirstDayOfWeek','0','0|1|2|3|4|5|6','Select the first day of week to use in the calendar.','Choice'), >diff --git a/koha-tmpl/intranet-tmpl/prog/css/src/_tables.scss b/koha-tmpl/intranet-tmpl/prog/css/src/_tables.scss >index 6f483a9e30..a62600d96f 100644 >--- a/koha-tmpl/intranet-tmpl/prog/css/src/_tables.scss >+++ b/koha-tmpl/intranet-tmpl/prog/css/src/_tables.scss >@@ -359,7 +359,7 @@ tbody { > border-right: 1px solid $table-border-color; > } > } >- &:nth-child(odd):not(.dtrg-group):not(.active) { >+ &:nth-child(odd):not(.dtrg-group):not(.active):not(.ok) { > td { > &:not(.bg-danger):not(.bg-warning):not(.bg-info):not(.bg-success):not(.bg-primary) { > background-color: $table-odd-row; >diff --git a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss >index 0afb6c768a..cb6d2c52a6 100644 >--- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss >+++ b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss >@@ -1973,6 +1973,12 @@ i { > font-style: italic; > } > >+// style for bundled detail in catalogsearch >+.bundled { >+ display: block; >+ font-style: italic; >+} >+ > #menu { > border-right: 1px solid #B9D8D9; > margin-right: .5em; >@@ -2355,6 +2361,14 @@ td { > display: block; > } > >+.bundled { >+ display: block; >+} >+ >+td.bundle { >+ background-color: #FFC !important; >+} >+ > .datedue { > color: #999; > display: block; >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers.inc >index fdcc6eadfa..6ec8a50c8f 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers.inc >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers.inc >@@ -115,6 +115,8 @@ > [% ELSE %] > [% IF subfield.IS_LOST_AV && Koha.Preference("ClaimReturnedLostValue") && aval == Koha.Preference("ClaimReturnedLostValue") %] > <option disabled="disabled" value="[%- aval | html -%]" title="Return claims must be processed from the patron details page">[%- mv.labels.$aval | html -%]</option> >+ [% ELSIF subfield.IS_LOST_AV && Koha.Preference("BundleLostValue") && aval == Koha.Preference("BundleLostValue") %] >+ <option disabled="disabled" value="[%- aval | html -%]" title="Bundle losses are set at checkin automatically">[%- mv.labels.$aval | html -%]</option> > [% ELSE %] > <option value="[%- aval | html -%]">[%- mv.labels.$aval | html -%]</option> > [% END %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/js-biblio-format.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/js-biblio-format.inc >new file mode 100644 >index 0000000000..fe43954c22 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/js-biblio-format.inc >@@ -0,0 +1,64 @@ >+<script> >+ (function() { >+ /** >+ * Format the biblio response from a Koha RESTful API request. >+ * @param {Object} biblio The biblio json object as returned from the Koha RESTful API >+ * @param {Object} config A configuration object >+ * Valid keys are: `link` >+ * @return {string} The formatted HTML string >+ */ >+ window.$biblio_to_html = function(biblio, config) { >+ >+ if (biblio === undefined) { >+ return ''; // empty string for no biblio >+ } >+ >+ var title = '<span class="biblio-title">'; >+ if (biblio.title != null && biblio.title != '') { >+ title += escape_str(biblio.title); >+ } else { >+ title += __("No title"); >+ } >+ title += '</span>'; >+ >+ // add subtitle >+ if (biblio.subtitle != null && biblio.subtitle != '') { >+ title += '<span class="biblio-subtitle">' + escape_str(biblio.subtitle) + '</span>'; >+ } >+ >+ // set title as link >+ if (config && config.link) { >+ if (config.link === 'marcdetail') { >+ title = '<a href="/cgi-bin/koha/catalogue/MARCdetail.pl?biblionumber=' + encodeURIComponent(biblio.biblio_id) + '" class="title">' + title + '</a>'; >+ } else if (config.link === 'labeled_marc') { >+ title = '<a href="/cgi-bin/koha/catalogue/labeledMARCdetail.pl?biblionumber=' + encodeURIComponent(biblio.biblio_id) + '" class="title">' + title + '</a>'; >+ } else if (config.link === 'isbd') { >+ title = '<a href="/cgi-bin/koha/catalogue/ISBDdetail.pl?biblionumber=' + encodeURIComponent(biblio.biblio_id) + '" class="title">' + title + '</a>'; >+ } else { >+ title = '<a href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + encodeURIComponent(biblio.biblio_id) + '" class="title">' + title + '</a>'; >+ } >+ } >+ >+ // add medium >+ if (biblio.medium != null && biblio.medium != '') { >+ title += '<span class="biblio-medium">' + escape_str(biblio.medium) + '</span>'; >+ } >+ >+ // add part numbers/names >+ let part_numbers = (typeof biblio.part_number === 'string') ? biblio.part_number.split("|") : []; >+ let part_names = (typeof biblio.part_name === 'string') ? biblio.part_name.split("|") : []; >+ let i = 0; >+ while (part_numbers[i] || part_names[i]) { >+ if (part_numbers[i]) { >+ title += '<span class="part-number">' + escape_str(part_numbers[i]) + '</span>'; >+ } >+ if (part_names[i]) { >+ title += '<span class="part-name">' + escape_str(part_name[i]) + '</span>'; >+ } >+ i++; >+ } >+ >+ return title; >+ }; >+ })(); >+</script> >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc >new file mode 100644 >index 0000000000..fe0041923c >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc >@@ -0,0 +1,35 @@ >+<!-- Bundle contents modal --> >+<div class="modal printable" id="bundleContentsModal" tabindex="-1" role="dialog" aria-labelledby="bundleContentsLabel"> >+ <div class="modal-dialog" role="document"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <button type="button" class="closebtn" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> >+ <h4 class="modal-title" id="bundleContentsLabel">Bundle contents for [% item.barcode | html %]</h4> >+ </div> >+ <div class="modal-body"> >+ <table style="width:100%"> >+ <thead> >+ <tr> >+ <th>Barcode</th> >+ <th>Title</th> >+ </tr> >+ </thead> >+ <tbody> >+ [% FOREACH bundle_item IN bundle_items %] >+ <tr> >+ <td>[% bundle_item.barcode | html %]</td> >+ <td>[% INCLUDE 'biblio-title.inc' biblio=bundle_item.biblio %]</td> >+ </tr> >+ [% END %] >+ </tbody> >+ <tfoot> >+ </tfoot> >+ </table> >+ </div> <!-- /.modal-body --> >+ <div class="modal-footer"> >+ <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> >+ <button type="button" class="printModal btn btn-primary"><i class="fa fa-print"></i> Print</button> >+ </div> <!-- /.modal-footer --> >+ </div> <!-- /.modal-content --> >+ </div> <!-- /.modal-dialog --> >+</div> <!-- /#bundleContentsModal --> >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref >index 3de1834c93..149053073b 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref >@@ -1284,6 +1284,17 @@ Circulation: > - pref: ArticleRequestsSupportedFormats > - "(Valid choices are currently: PHOTOCOPY and SCAN. Separate the supported formats by a vertical bar. The first listed format is selected by default when you request via the OPAC.)" > >+ >+ Item bundles: >+ - >+ - Use the LOST authorised value >+ - pref: BundleLostValue >+ - to represent 'missing from bundle' at return. >+ - >+ - Use the NOT_LOAN authorised value >+ - pref: BundleNotLoanValue >+ - to represent 'added to bundle' when an item is attached to bundle. >+ > Return claims: > - > - When marking a checkout as "claims returned", >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >index eeecfd1669..14ac3460a4 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >@@ -368,12 +368,12 @@ > [% IF ( analyze ) %]<th id="[% tab | html %]_usedin" data-colname="[% tab | html %]_usedin">Used in</th><th></th>[% END %] > [% IF ( ShowCourseReserves ) %]<th id="[% tab | html %]_course_reserves" data-colname="[% tab | html %]_course_reserves">Course reserves</th>[% END %] > [% IF ( SpineLabelShowPrintOnBibDetails ) %]<th id="[% tab | html %]_spinelabel" data-colname="[% tab | html %]_spinelabel" class="NoSort">Spine label</th>[% END %] >- [% IF ( CAN_user_editcatalogue_edit_items ) %]<th id="[% tab | html %]_actions" data-colname="[% tab | html %]_actions"class="NoSort"> </th>[% END %] >+ [% IF ( CAN_user_editcatalogue_edit_items ) %]<th id="[% tab | html %]_actions" data-colname="[% tab | html %]_actions"class="NoSort noExport"> </th>[% END %] > </tr> > </thead> > <tbody> > [% FOREACH item IN items %] >- <tr> >+ <tr id="item_[% item.itemnumber | html %]" data-itemnumber="[% item.itemnumber | html %]" data-duedate="[% item.datedue | html %]"> > [% IF (StaffDetailItemSelection) %] > <td style="text-align:center;vertical-align:middle"> > <input type="checkbox" value="[% item.itemnumber | html %]" name="itemnumber" /> >@@ -545,6 +545,11 @@ Note that permanent location is a code, and location may be an authval. > [% IF ( item.restricted ) %] > <span class="restricted">([% item.restrictedvalue | html %])</span> > [% END %] >+ >+ [% IF ( item.bundle_host ) %] >+ <span class="bundled">In bundle: [% INCLUDE 'biblio-title.inc' biblio = item.bundle_host.biblio link = 1 %]</span> >+ [% END %] >+ > </td> > <td class="datelastseen" data-order="[% item.datelastseen | html %]">[% item.datelastseen | $KohaDates %]</td> > <td class="issues" data-order="[% item.issues || 0 | html %]">[% item.issues || 0 | html %]</td> >@@ -631,6 +636,9 @@ Note that permanent location is a code, and location may be an authval. > <a class="btn btn-default btn-xs" href="/cgi-bin/koha/cataloguing/additem.pl?op=edititem&biblionumber=[% item.biblionumber | html %]&itemnumber=[% item.itemnumber | html %]#edititem"><i class="fa fa-pencil"></i> Edit</a> > [% END %] > [% END %] >+ [% IF bundlesEnabled %] >+ <button class="btn btn-default btn-xs details-control"><i class="fa fa-folder"></i> Manage bundle ([% item.bundled | html %]|[% item.bundled_lost | html %])</button> >+ [% END %] > </td> > [% END %] > </tr> >@@ -1222,6 +1230,66 @@ Note that permanent location is a code, and location may be an authval. > </div> > </div> > >+ [% IF bundlesEnabled %] >+ <div class="modal" id="addToBundleModal" tabindex="-1" role="dialog" aria-labelledby="addToBundleLabel"> >+ <form id="addToBundleForm" action=""> >+ <div class="modal-dialog" role="document"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <button type="button" class="closebtn" data-dismiss="modal" aria-hidden="true">Ã</button> >+ <h3 id="addToBundleLabel">Add to bundle</h3> >+ </div> >+ <div class="modal-body"> >+ <div id="addResult"></div> >+ <fieldset class="rows"> >+ <ol> >+ <li> >+ <label class="required" for="external_id">Item barcode: </label> >+ <input type="text" id="external_id" name="external_id" required="required"> >+ <span class="required">Required</span> >+ </li> >+ </ol> >+ </fieldset> >+ </div> >+ <div class="modal-footer"> >+ <button type="submit" class="btn btn-default">Submit</button> >+ <button class="btn btn-default" data-dismiss="modal" aria-hidden="true">Close</button> >+ </div> >+ </div> >+ </div> >+ </form> >+ </div> >+ >+ <div class="modal" id="removeFromBundleModal" tabindex="-1" role="dialog" aria-labelledby="removeFromBundleLabel"> >+ <form id="removeFromBundleForm" action=""> >+ <div class="modal-dialog" role="document"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <button type="button" class="closebtn" data-dismiss="modal" aria-hidden="true">Ã</button> >+ <h3 id="removeFromBundleLabel">Remove from bundle</h3> >+ </div> >+ <div class="modal-body"> >+ <div id="removeResult"></div> >+ <fieldset class="rows"> >+ <ol> >+ <li> >+ <label class="required" for="external_id">Item barcode: </label> >+ <input type="text" id="rm_external_id" name="external_id" required="required"> >+ <span class="required">Required</span> >+ </li> >+ </ol> >+ </fieldset> >+ </div> >+ <div class="modal-footer"> >+ <button type="submit" class="btn btn-default">Submit</button> >+ <button class="btn btn-default" data-dismiss="modal" aria-hidden="true">Close</button> >+ </div> >+ </div> >+ </div> >+ </form> >+ </div> >+ [% END %] >+ > [% MACRO jsinclude BLOCK %] > [% INCLUDE 'catalog-strings.inc' %] > [% Asset.js("js/catalog.js") | $raw %] >@@ -1528,6 +1596,9 @@ Note that permanent location is a code, and location may be an authval. > [% INCLUDE 'datatables.inc' %] > [% Asset.js("lib/jquery/plugins/jquery.dataTables.columnFilter.js") | $raw %] > [% INCLUDE 'columns_settings.inc' %] >+ [% INCLUDE 'js-date-format.inc' %] >+ [% INCLUDE 'js-patron-format.inc' %] >+ [% INCLUDE 'js-biblio-format.inc' %] > [% Asset.js("js/browser.js") | $raw %] > [% Asset.js("js/table_filters.js") | $raw %] > <script> >@@ -1535,7 +1606,275 @@ Note that permanent location is a code, and location may be an authval. > browser = KOHA.browser('[% searchid | html %]', parseInt(biblionumber, 10)); > browser.show(); > >+ [% IF bundlesEnabled %] >+ var bundle_settings = [% TablesSettings.GetTableSettings('catalogue', 'detail','bundle_tables','json') | $raw %]; >+ var bundle_lost_value = [% Koha.Preference('BundleLostValue') | html %]; >+ [% END %] > $(document).ready(function() { >+ >+ [% IF bundlesEnabled %] // Bundle handling >+ function createChild ( row, itemnumber, duedate ) { >+ >+ // Toolbar >+ var bundle_toolbar = $('<div id="toolbar" class="btn-toolbar"></div>'); >+ bundle_toolbar.append('<a class="btn btn-default" data-toggle="modal" data-target="#addToBundleModal" data-item="' + itemnumber + '"><i class="fa fa-plus"></i> ' + _("Add to bundle") + '</a>'); >+ bundle_toolbar.append('<a class="btn btn-default" data-toggle="modal" data-target="#removeFromBundleModal" data-item="' + itemnumber + '"><i class="fa fa-minus"></i> ' + _("Remove from bundle") + '</a>'); >+ >+ // Disable management if there's a duedate >+ if(duedate) { >+ bundle_toolbar.children('.btn').addClass("disabled"); >+ } >+ >+ // This is the table we'll convert into a DataTable >+ var bundles_table = $('<table class="display tbundle" data-itemnumber="'+itemnumber+'" id="bundle_table_'+itemnumber+'" width="100%"/>'); >+ >+ // Display it the child row >+ row.child( bundle_toolbar.add(bundles_table), 'bundle' ).show(); >+ >+ // Initialise as a DataTable >+ var bundle_table_url = "/api/v1/items/" + itemnumber + "/bundled_items?"; >+ var bundle_table = bundles_table.kohaTable({ >+ "ajax": { >+ "url": bundle_table_url >+ }, >+ "header_filter": false, >+ "embed": [ >+ "biblio", >+ "return_claim.patron" >+ ], >+ "order": [[ 1, "asc" ]], >+ "columnDefs": [ { >+ "targets": [0,1,2,3], >+ "render": function (data, type, row, meta) { >+ if ( data && type == 'display' ) { >+ return data.escapeHtml(); >+ } >+ return data; >+ } >+ } ], >+ "columns": [ >+ { >+ "data": "biblio.title:biblio.subtitle:biblio.medium", >+ "title": _("Title"), >+ "searchable": true, >+ "orderable": true, >+ "render": function(data, type, row, meta) { >+ return $biblio_to_html(row.biblio, { link: 1 }); >+ } >+ }, >+ { >+ "data": "biblio.author", >+ "title": _("Author"), >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "callnumber", >+ "title": _("Callnumber"), >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "external_id", >+ "title": _("Barcode"), >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "lost_status:last_seen_date:return_claim.patron", >+ "title": _("Status"), >+ "searchable": false, >+ "orderable": true, >+ "render": function(data, type, row, meta) { >+ if ( row.lost_status == bundle_lost_value ) { >+ let out = '<span class="lost">' + _("Last seen") + ': ' + $date(row.last_seen_date) + '</span>'; >+ if ( row.return_claim ) { >+ out = out + '<span class="claims_return">' + _("Claims returned by") + ': ' + $patron_to_html( row.return_claim.patron, { display_cardnumber: false, url: true } ) + '</span>'; >+ } >+ return out; >+ } >+ else if ( row.lost_status !== 0 ) { >+ return '<span class="lost">' + _("Lost") + ': ' + row.lost_status + '</span>'; >+ } >+ return '<span class="available">' + _("Present") + '</span>'; >+ } >+ }, >+ { >+ "data": function( row, type, val, meta ) { >+ var result; >+ if (duedate) { >+ result = '<button class="btn btn-default btn-xs remove disabled" role="button" data-itemnumber="'+row.item_id+'"><i class="fa fa-minus" aria-hidden="true"></i> '+_("Remove")+'</button>\n'; >+ } else { >+ result = '<button class="btn btn-default btn-xs remove" role="button" data-itemnumber="'+row.item_id+'"><i class="fa fa-minus" aria-hidden="true"></i> '+_("Remove")+'</button>\n'; >+ } >+ return result; >+ }, >+ "title": _("Actions"), >+ "searchable": false, >+ "orderable": false, >+ "class": "noExport" >+ } >+ ] >+ }, bundle_settings, 1); >+ $(".tbundle").on("click", ".remove:not(.disabled)", function(){ >+ var bundle_table = $(this).closest('table'); >+ var host_itemnumber = bundle_table.data('itemnumber'); >+ var component_itemnumber = $(this).data('itemnumber'); >+ var unlink_item_url = "/api/v1/items/" + host_itemnumber + "/bundled_items/" + component_itemnumber; >+ $.ajax({ >+ type: "DELETE", >+ url: unlink_item_url, >+ success: function(){ >+ bundle_table.DataTable({ 'retrieve': true }).draw(false); >+ } >+ }); >+ }); >+ >+ return; >+ } >+ >+ var bundle_changed; >+ var bundle_form_active; >+ $("#addToBundleModal").on("shown.bs.modal", function(e){ >+ var button = $(e.relatedTarget); >+ var item_id = button.data('item'); >+ $("#addResult").replaceWith('<div id="addResult"></div>'); >+ $("#addToBundleForm").attr('action', '/api/v1/items/' + item_id + '/bundled_items'); >+ $("#external_id").focus(); >+ bundle_changed = 0; >+ bundle_form_active = item_id; >+ }); >+ >+ $("#addToBundleForm").submit(function(event) { >+ >+ /* stop form from submitting normally */ >+ event.preventDefault(); >+ >+ /* get the action attribute from the <form action=""> element */ >+ var $form = $(this), >+ url = $form.attr('action'); >+ >+ /* Send the data using post with external_id */ >+ var posting = $.post({ >+ url: url, >+ data: JSON.stringify({ external_id: $('#external_id').val()}), >+ contentType: "application/json; charset=utf-8", >+ dataType: "json" >+ }); >+ >+ /* Report the results */ >+ posting.done(function(data) { >+ var barcode = $('#external_id').val(); >+ $('#addResult').replaceWith('<div id="addResult" class="alert alert-success">'+_("Success: Added '%s'").format(barcode)+'</div>'); >+ $('#external_id').val('').focus(); >+ bundle_changed = 1; >+ }); >+ posting.fail(function(data) { >+ var barcode = $('#external_id').val(); >+ if ( data.status === 409 ) { >+ var response = data.responseJSON; >+ if ( response.key === "PRIMARY" ) { >+ $('#addResult').replaceWith('<div id="addResult" class="alert alert-warning">'+_("Warning: Item '%s' already attached").format(barcode)+'</div>'); >+ } else { >+ $('#addResult').replaceWith('<div id="addResult" class="alert alert-danger">'+_("Failure: Item '%s' belongs to another bundle").format(barcode)+'</div>'); >+ } >+ } else if ( data.status === 404 ) { >+ $('#addResult').replaceWith('<div id="addResult" class="alert alert-danger">'+_("Failure: Item '%s' not found").format(barcode)+'</div>'); >+ } else { >+ $('#addResult').replaceWith('<div id="addResult" class="alert alert-danger">'+_("Failure: Check the logs for details")+'</div>'); >+ } >+ $('#external_id').val('').focus(); >+ }); >+ }); >+ >+ $("#addToBundleModal").on("hidden.bs.modal", function(e){ >+ if ( bundle_changed ) { >+ $('#bundle_table_'+bundle_form_active).DataTable({ 'retrieve': true }).ajax.reload(); >+ } >+ bundle_form_active = 0; >+ bundle_changed = 0; >+ }); >+ >+ $("#removeFromBundleModal").on("shown.bs.modal", function(e){ >+ var button = $(e.relatedTarget); >+ var item_id = button.data('item'); >+ $("#removeResult").replaceWith('<div id="removeResult"></div>'); >+ $("#removeFromBundleForm").attr('action', '/api/v1/items/' + item_id + '/bundled_items/'); >+ $("#rm_external_id").focus(); >+ bundle_changed = 0; >+ bundle_form_active = item_id; >+ }); >+ >+ $("#removeFromBundleForm").submit(function(event) { >+ >+ /* stop form from submitting normally */ >+ event.preventDefault(); >+ >+ /* get the action attribute from the <form action=""> element */ >+ var $form = $(this), >+ url = $form.attr('action'); >+ >+ var barcode = $('#rm_external_id').val(); >+ >+ /* Fetch itemnumber using rm_external_id */ >+ var itemReq = $.get('/api/v1/items', { q: JSON.stringify({ >+ external_id: barcode >+ }) }, null, "json"); >+ >+ var itemnumber; >+ itemReq.done(function(data) { >+ if (data.length === 1) { >+ itemnumber = data[0].item_id; >+ >+ /* Remove link using fetch itemnumber */ >+ var deleteReq = $.ajax( url + itemnumber, { >+ type : 'DELETE' >+ }); >+ >+ /* Report the results */ >+ deleteReq.done(function(data) { >+ var barcode = $('#rm_external_id').val(); >+ $('#removeResult').replaceWith('<div id="removeResult" class="alert alert-success">'+_("Success: Removed '%s'").format(barcode)+'</div>'); >+ $('#rm_external_id').val('').focus(); >+ bundle_changed = 1; >+ }); >+ deleteReq.fail(function(data) { >+ var barcode = $('#rm_external_id').val(); >+ if ( data.status === 409 ) { >+ var response = data.responseJSON; >+ if ( response.key === "PRIMARY" ) { >+ $('#removeResult').replaceWith('<div id="removeResult" class="alert alert-warning">'+_("Warning: Item '%s' already attached").format(barcode)+'</div>'); >+ } else { >+ $('#removeResult').replaceWith('<div id="removeResult" class="alert alert-danger">'+_("Failure: Item '%s' belongs to another bundle").format(barcode)+'</div>'); >+ } >+ } else if ( data.status === 404 ) { >+ $('#addResult').replaceWith('<div id="addResult" class="alert alert-danger">'+_("Failure: Item '%s' not found").format(barcode)+'</div>'); >+ } else { >+ $('#removeResult').replaceWith('<div id="removeResult" class="alert alert-danger">'+_("Failure: Check the logs for details")+'</div>'); >+ } >+ $('#rm_external_id').val('').focus(); >+ }); >+ } else { >+ $('#removeResult').replaceWith('<div id="removeResult" class="alert alert-danger">'+_("Failed: Barcode matched more than one item '%s'").format(barcode)+'</div>'); >+ } >+ }); >+ itemReq.fail(function(data) { >+ $('#removeResult').replaceWith('<div id="removeResult" class="alert alert-danger">'+_("Failed: Item not found '%s'").format(barcode)+'</div>'); >+ $('#rm_external_id').val('').focus(); >+ >+ }); >+ }); >+ >+ $("#removeFromBundleModal").on("hidden.bs.modal", function(e){ >+ if ( bundle_changed ) { >+ $('#bundle_table_'+bundle_form_active).DataTable({ 'retrieve': true }).ajax.reload(); >+ } >+ bundle_form_active = 0; >+ bundle_changed = 0; >+ }); >+ // End bundle handling >+ [% END %] >+ > var table_names = [ 'holdings_table', 'otherholdings_table' ]; > var table_settings = [ [% TablesSettings.GetTableSettings('catalogue', 'detail','holdings_table','json') | $raw %], [% TablesSettings.GetTableSettings('catalogue', 'detail','otherholdings_table','json') | $raw %] ]; > var has_images = [ "[% itemloop_has_images | html %]", "[% otheritemloop_has_images | html %]" ]; >@@ -1551,6 +1890,32 @@ Note that permanent location is a code, and location may be an authval. > "sDom": 'C<"top pager"ilpfB><"#filter_c">tr<"bottom pager"ip>', > }; > var table = KohaTable( table_name, dt_parameters, table_settings[index], 'with_filters' ); >+ >+ [% IF bundlesEnabled %] >+ // Add event listener for opening and closing bundle details >+ $('#' + table_name + ' tbody').on('click', 'button.details-control', function () { >+ var button = $(this); >+ var tr = button.closest('tr'); >+ var dTable = button.closest('table').DataTable({ 'retrieve': true }); >+ >+ var itemnumber = tr.data('itemnumber'); >+ var duedate = tr.data('duedate'); >+ var row = dTable.row( tr ); >+ >+ if ( row.child.isShown() ) { >+ // This row is already open - close it >+ row.child.hide(); >+ tr.removeClass('shown'); >+ button.removeClass('active'); >+ } >+ else { >+ // Open this row >+ createChild(row, itemnumber, duedate); >+ tr.addClass('shown'); >+ button.addClass('active'); >+ } >+ } ); >+ [% END %] > }); > > [% IF Koha.Preference('AcquisitionDetails') %] >@@ -1572,6 +1937,7 @@ Note that permanent location is a code, and location may be an authval. > "sPaginationType": "full" > })); > [% END %] >+ > }); > > [% IF (found1) %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation.tt >index f433448406..9a3131d730 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation.tt >@@ -842,7 +842,7 @@ > </li> > [% END %] > >- [% IF Koha.Preference('ClaimReturnedLostValue') %] >+ [% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %] > <li> > [% IF ( patron.return_claims.count ) %] > <a href="#return-claims" id="return-claims-tab"> >@@ -970,7 +970,7 @@ > </div> > [% END %] > >- [% IF Koha.Preference('ClaimReturnedLostValue') %] >+ [% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %] > [% INCLUDE 'patron-return-claims.inc' %] > [% END %] > >@@ -1006,7 +1006,7 @@ > </div> <!-- /.row --> > </main> > >- [% IF Koha.Preference('ClaimReturnedLostValue') %] >+ [% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %] > [% INCLUDE 'modals/resolve_return_claim.inc' %] > [% END %] > >@@ -1054,7 +1054,7 @@ > </script> > [% Asset.js("js/pages/circulation.js") | $raw %] > [% Asset.js("js/checkouts.js") | $raw %] >- [% IF Koha.Preference('ClaimReturnedLostValue') %] >+ [% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %] > [% Asset.js("js/resolve_claim_modal.js") | $raw %] > [% END %] > [% Asset.js("js/holds.js") | $raw %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt >index c97b4a32da..dd295153a3 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt >@@ -6,6 +6,7 @@ > [% USE ItemTypes %] > [% USE AuthorisedValues %] > [% USE TablesSettings %] >+[% PROCESS 'i18n.inc' %] > [% PROCESS 'member-display-address-style.inc' %] > [% SET footerjs = 1 %] > [% BLOCK display_bormessagepref %] >@@ -217,6 +218,39 @@ > </div> > [% END %] > >+ <!-- Bundle has items missing --> >+ [% IF missing_items %] >+ <div id="bundle_missing_items" class="dialog alert"> >+ <h3>Bundle had missing items</h3> >+ <p>Bundle contents list updated</p> >+ <p> >+ <a class="btn btn-default btn-xs" role="button" data-toggle="modal" href="#bundleContentsModal"><i class="fa fa-eye" aria-hidden="true"></i> View updated contents list</a> >+ <a class="btn btn-default btn-xs" role="button" data-toggle="modal" href="#bundleMissingModal"><i class="fa fa-eye" aria-hidden="true"></i> View list of missing items</a> >+ </p> >+ </div> >+ [% END %] >+ >+ <!-- Bundle contained unexpected items --> >+ [% IF unexpected_items %] >+ <div id="bundle_unexpected_items" class="dialog alert"> >+ <h3>Bundle had unexpected items</h3> >+ <p>Please place the following items to one side</p> >+ <ul> >+ [% FOREACH unexpected_item IN unexpected_items %] >+ <li>[% INCLUDE 'biblio-title.inc' biblio=unexpected_item.biblio %] - [% unexpected_item.barcode | html %]</li> >+ [% END %] >+ </ul> >+ </div> >+ [% END %] >+ >+ <!-- Item checked in outside of bundle --> >+ [% IF InBundle %] >+ <div id="bundle_item_outside" class="dialog alert audio-alert-warning"> >+ <h3>Item belongs in bundle</h3> >+ <p>This item belongs to a bundle: [% INCLUDE 'biblio-title.inc' biblio=InBundle.biblio %] - [% InBundle.barcode | html %]</p> >+ <p><button class="btn btn-default btn-xs bundle_remove" role="button" data-itemnumber="[% itemnumber | uri %]" data-hostnumber="[% InBundle.itemnumber | uri %]"><i class="fa fa-minus"></i> Remove from bundle</button></p> >+ </div> >+ [% END %] > > [% IF ( errmsgloop ) %] > <div class="dialog alert audio-alert-warning"> >@@ -347,6 +381,14 @@ > <p class="ret_checkinmsg">[% checkinmsg | html_line_break %]</p> > </div> > [% END # /IF checkinmsg %] >+ >+ [% IF bundle_items && !missing_items %] >+ <div class="dialog message"> >+ <h3>Bundle verified</h3> >+ <p>The bundle content was verified</p> >+ <p><a class="btn btn-default btn-xs" role="button" data-toggle="modal" href="#bundleContentsModal"><i class="fa fa-eye" aria-hidden="true"></i> View contents list</a></p> >+ </div> >+ [% END %] > [% END # /BLOCK all_checkin_messages %] > > [% IF needs_confirm %] >@@ -381,6 +423,91 @@ > </div> > [% END %] > >+ [% IF items_bundle_return_confirmation %] >+ <div id="bundle-needsconfirmation-modal" class="modal fade audio-alert-action block"> >+ <div class="modal-dialog modal-wide"> >+ <div class="modal-content"> >+ <form method="post"> >+ <div class="modal-header"> >+ <h3>Please confirm bundle contents for [% item.barcode | html %]</h3> >+ </div> >+ <div class="modal-body"> >+ >+ <table class="table table-condensed table-bordered" id="items-bundle-contents-table"> >+ <thead> >+ <tr> >+ <th>Title</th> >+ <th>Author</th> >+ <th>Item type</th> >+ <th>Barcode</th> >+ [% IF !item.onloan %] >+ <th>Status</th> >+ [% END %] >+ </tr> >+ </thead> >+ <tbody> >+ [% FOREACH bundle_item IN item.bundle_items %] >+ [% IF !item.onloan %] >+ <tr data-barcode="[% bundle_item.barcode | html %]"> >+ <td>[% INCLUDE 'biblio-title.inc' biblio=bundle_item.biblio link = 1 %]</td> >+ <td>[% bundle_item.biblio.author | html %]</td> >+ <td>[% ItemTypes.GetDescription(bundle_item.itype) | html %]</td> >+ <td>[% bundle_item.barcode | html %]</td> >+ <td> >+ [% IF bundle_item.itemlost %] >+ [% itemlost_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.itemlost', authorised_value = bundle_item.itemlost }) %] >+ <span class="lost">[% itemlost_description | html %]</span> >+ [% ELSE %] >+ Present >+ [% END %] >+ </td> >+ </tr> >+ [% ELSIF !bundle_item.itemlost %] >+ <tr data-barcode="[% bundle_item.barcode | html %]"> >+ <td>[% INCLUDE 'biblio-title.inc' biblio=bundle_item.biblio link = 1 %]</td> >+ <td>[% bundle_item.biblio.author | html %]</td> >+ <td>[% ItemTypes.GetDescription(bundle_item.itype) | html %]</td> >+ <td>[% bundle_item.barcode | html %]</td> >+ </tr> >+ [% END %] >+ [% END %] >+ </tbody> >+ </table> >+ >+ <div class="form-group"> >+ <label for="verify-items-bundle-contents-barcodes">Barcodes <span id="verify-progress" class="pull-right" style="display: none"><span id="verified">0</span> of <span id="expected"></span> verified</span></label> >+ <textarea autocomplete="off" id="verify-items-bundle-contents-barcodes" name="verify-items-bundle-contents-barcodes" class="form-control"></textarea> >+ [% IF item.onloan %] >+ <div class="help-block">Scan all barcodes of items found in the items bundle. If any items are missing, they will be marked as lost</div> >+ [% ELSE %] >+ <div class="help-block">Optionally scan all barcodes of items found in the items bundle to perform an inventory check. If any items are missing, they will be marked as lost</div> >+ [% END %] >+ </div> >+ >+ <div id="bundle-feedback" class="alert" style="display:none"></div> >+ >+ </div> >+ <div class="modal-footer"> >+ <input type="hidden" name="barcode" value="[% item.barcode | html %]"> >+ <input type="hidden" name="confirm_items_bundle_return" value="1"> >+ [% FOREACH inputloo IN inputloop %] >+ <input type="hidden" name="ri-[% inputloo.counter | html %]" value="[% inputloo.barcode | html %]" /> >+ <input type="hidden" name="dd-[% inputloo.counter | html %]" value="[% inputloo.duedate | html %]" /> >+ <input type="hidden" name="bn-[% inputloo.counter | html %]" value="[% inputloo.borrowernumber | html %]" /> >+ [% END %] >+ [% IF item.onloan %] >+ <button type="submit" class="btn btn-default"><i class="fa fa-check"></i> Confirm checkin and mark missing items as lost</button> >+ [% ELSE %] >+ <button type="submit" class="btn btn-default"><i class="fa fa-check"></i> Confirm inventory check and mark items as lost</button> >+ [% END %] >+ <button type="button" data-dismiss="modal" class="btn btn-default"><i class="fa fa-close"></i> Cancel</button> >+ </div> >+ </form> >+ </div> >+ </div> >+ </div> >+ [% END %] >+ > [% IF wrongbranch %] > <div id="wrong-branch-modal" class="modal fade audio-alert-action block"> > <div class="modal-dialog"> >@@ -1180,6 +1307,46 @@ > [% INCLUDE 'modals/resolve_return_claim.inc' %] > [% END %] > >+ [% INCLUDE 'modals/bundle_contents.inc' %] >+ >+ [% IF ( missing_items ) %] >+ <!-- Bundle missing modal --> >+ <div class="modal printable" id="bundleMissingModal" tabindex="-1" role="dialog" aria-labelledby="bundleMissingLabel"> >+ <div class="modal-dialog" role="document"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <button type="button" class="closebtn" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> >+ <h4 class="modal-title" id="bundleMissingLabel">Items missing from bundle at checking for [% item.barcode | html %]</h4> >+ </div> >+ <div class="modal-body"> >+ <table style="width:100%"> >+ <thead> >+ <tr> >+ <th>Barcode</th> >+ <th>Title</th> >+ </tr> >+ </thead> >+ <tbody> >+ [% FOREACH bundle_item IN missing_items %] >+ <tr> >+ <td>[% bundle_item.barcode | html %]</td> >+ <td>[% INCLUDE 'biblio-title.inc' biblio=bundle_item.biblio %]</td> >+ </tr> >+ [% END %] >+ </tbody> >+ <tfoot> >+ </tfoot> >+ </table> >+ </div> <!-- /.modal-body --> >+ <div class="modal-footer"> >+ <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> >+ <button type="button" class="printModal btn btn-primary"><i class="fa fa-print"></i> Print</button> >+ </div> <!-- /.modal-footer --> >+ </div> <!-- /.modal-content --> >+ </div> <!-- /.modal-dialog --> >+ </div> <!-- /#bundleMissingModal --> >+ [% END %] >+ > [% MACRO jsinclude BLOCK %] > [% INCLUDE 'datatables.inc' %] > [% INCLUDE 'columns_settings.inc' %] >@@ -1364,8 +1531,83 @@ > window.open("/cgi-bin/koha/members/printslip.pl?borrowernumber=" + borrowernumber + "&print=checkinslip", "printwindow"); > }); > >+ // item bundles >+ $('#verify-items-bundle-contents-barcodes').on('input', function (ev) { >+ let char = ev.target.value.slice(-1); >+ if ( char.match(/\n/) ) { >+ const barcodes = ev.target.value.split('\n').map(function(s) { return s.trim().toUpperCase() }); >+ const expected = []; >+ let found = 0; >+ $('#items-bundle-contents-table tbody > tr').each(function () { >+ const barcode = this.getAttribute('data-barcode').toUpperCase(); >+ expected.push(barcode); >+ if (barcodes.includes(barcode)) { >+ this.classList.add('ok'); >+ found++; >+ } else { >+ this.classList.remove('ok'); >+ } >+ }); >+ const last = barcodes[barcodes.length -2]; >+ const feedback = $('#bundle-feedback'); >+ let string; >+ if ( !expected.includes(last) ) { >+ feedback.fadeOut(100, function(){ >+ string = _("Unexpected: ") +last; >+ feedback.addClass('alert-danger').removeClass('alert-success').html(string).fadeIn(100); >+ }); >+ } else { >+ feedback.fadeOut(100, function(){ >+ string = _("Verified: ")+last; >+ feedback.addClass('alert-success').removeClass('alert-danger').html(string).fadeIn(100); >+ }); >+ } >+ $('#verify-progress').show(); >+ $('#verified').text(found); >+ $('#expected').text(expected.length); >+ } >+ }); >+ >+ $('.bundle_remove').on('click', function() { >+ var component_itemnumber = $(this).data('itemnumber'); >+ var host_itemnumber = $(this).data('hostnumber'); >+ var alert = $(this).closest('div'); >+ var unlink_item_url = "/api/v1/items/" + host_itemnumber + "/bundled_items/item/" + component_itemnumber; >+ $.ajax({ >+ type: "DELETE", >+ url: unlink_item_url, >+ success: function(){ >+ alert.remove(); >+ } >+ }); >+ }); >+ >+ $('#items-bundle-contents-table').dataTable($.extend(true, {}, dataTablesDefaults, { >+ "bFilter": false, >+ "bPaginate": false, >+ "bInfo": false, >+ "order": [[ 1, 'asc' ], [ 0, 'asc' ]] >+ })); >+ >+ // print modals >+ $('.modal.printable').on('shown.bs.modal', function() { >+ $('.modal-dialog', this).addClass('focused'); >+ $('body').addClass('modalprinter'); >+ >+ if ($(this).hasClass('autoprint')) { >+ window.print(); >+ } >+ }).on('hidden.bs.modal', function() { >+ $('.modal-dialog', this).removeClass('focused'); >+ $('body').removeClass('modalprinter'); >+ }); >+ >+ $('.printModal').click(function() { >+ window.print(); >+ }); > }); > </script> >+ > [% END %] > > [% INCLUDE 'intranet-bottom.inc' %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/members/moremember.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/members/moremember.tt >index bf90be1d45..98ab46586d 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/moremember.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/members/moremember.tt >@@ -671,7 +671,7 @@ > </li> > [% END %] > >- [% IF Koha.Preference('ClaimReturnedLostValue') %] >+ [% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %] > <li> > [% IF ( patron.return_claims.count ) %] > <a href="#return-claims" id="return-claims-tab"> >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/item-status.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/item-status.inc >index 7b5688c0e1..314798f928 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/item-status.inc >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/item-status.inc >@@ -76,6 +76,10 @@ > <span class="item-status notforloan">Not for loan [% IF ( item.restrictedvalueopac ) %]<span class="restricted">([% item.restrictedvalueopac | html %])</span>[% END %]</span> > [% END %] > >+[% IF ( item.bundle_host ) %] >+ <span class="bundled">In bundle: <a href="/cgi-bin/koha/opac-detail.pl?biblionumber=[% item.bundle_host.biblionumber | uri %]">[% INCLUDE 'biblio-title.inc' biblio = item.bundle_host.biblio %]</a></span> >+[% END %] >+ > [% IF ( item.damaged ) %] > [% SET itemavailable = 0 %] > [% av_lib_include = AuthorisedValues.GetDescriptionByKohaField( kohafield => 'items.damaged', authorised_value => item.damaged, opac => 1 ) %] >diff --git a/opac/opac-detail.pl b/opac/opac-detail.pl >index acd20e6308..65c6d0d624 100755 >--- a/opac/opac-detail.pl >+++ b/opac/opac-detail.pl >@@ -766,6 +766,11 @@ if ( not $viewallitems and @items > $max_items_to_display ) { > $itm->{cover_images} = $item->cover_images; > } > >+ if ( $item->in_bundle ) { >+ my $host = $item->bundle_host; >+ $itm->{bundle_host} = $host; >+ } >+ > my $itembranch = $itm->{$separatebranch}; > if ($currentbranch and C4::Context->preference('OpacSeparateHoldings')) { > if ($itembranch and $itembranch eq $currentbranch) { >diff --git a/t/db_dependent/Accounts.t b/t/db_dependent/Accounts.t >index f468cd98e0..b35c659dc0 100755 >--- a/t/db_dependent/Accounts.t >+++ b/t/db_dependent/Accounts.t >@@ -555,18 +555,22 @@ subtest "C4::Accounts::chargelostitem tests" => sub { > my $cli_itemnumber2 = $builder->build_sample_item({ itype => $itype_replace_no_fee->{itemtype} })->itemnumber; > my $cli_itemnumber3 = $builder->build_sample_item({ itype => $itype_no_replace_fee->{itemtype} })->itemnumber; > my $cli_itemnumber4 = $builder->build_sample_item({ itype => $itype_replace_fee->{itemtype} })->itemnumber; >+ my $cli_itemnumber5 = $builder->build_sample_item({ itype => $itype_replace_fee->{itemtype} })->itemnumber; >+ my $cli_bundle1 = $builder->build_sample_item({ itype => $itype_no_replace_no_fee->{itemtype} })->itemnumber; >+ $schema->resultset('ItemBundle')->create( { host => $cli_bundle1, item => $cli_itemnumber5 } ); > > my $cli_issue_id_1 = $builder->build({ source => 'Issue', value => { borrowernumber => $cli_borrowernumber, itemnumber => $cli_itemnumber1 } })->{issue_id}; > my $cli_issue_id_2 = $builder->build({ source => 'Issue', value => { borrowernumber => $cli_borrowernumber, itemnumber => $cli_itemnumber2 } })->{issue_id}; > my $cli_issue_id_3 = $builder->build({ source => 'Issue', value => { borrowernumber => $cli_borrowernumber, itemnumber => $cli_itemnumber3 } })->{issue_id}; > my $cli_issue_id_4 = $builder->build({ source => 'Issue', value => { borrowernumber => $cli_borrowernumber, itemnumber => $cli_itemnumber4 } })->{issue_id}; > my $cli_issue_id_4X = undef; >+ my $cli_bundle_issue = $builder->build({ source => 'Issue', value => { borrowernumber => $cli_borrowernumber, itemnumber => $cli_bundle1 } })->{issue_id}; > > my $lostfine; > my $procfee; > > subtest "fee application tests" => sub { >- plan tests => 44; >+ plan tests => 48; > > t::lib::Mocks::mock_preference('item-level_itypes', '1'); > t::lib::Mocks::mock_preference('useDefaultReplacementCost', '0'); >@@ -694,6 +698,16 @@ subtest "C4::Accounts::chargelostitem tests" => sub { > ok( $procfees->count == 2, "Processing fee can be charged twice for the same item if they are distinct issue_id's"); > $lostfines->delete(); > $procfees->delete(); >+ >+ C4::Accounts::chargelostitem( $cli_borrowernumber, $cli_itemnumber5, 6.12, "Bundle"); >+ $lostfine = Koha::Account::Lines->find({ borrowernumber => $cli_borrowernumber, itemnumber => $cli_itemnumber5, debit_type_code => 'LOST' }); >+ $procfee = Koha::Account::Lines->find({ borrowernumber => $cli_borrowernumber, itemnumber => $cli_itemnumber5, debit_type_code => 'PROCESSING' }); >+ is( $lostfine->amount, "6.120000", "Lost fine equals replacementcost when pref on and default set (Bundle)"); >+ is( $procfee->amount, "2.040000", "Processing fee if processing fee (Bundle)"); >+ is( $lostfine->issue_id, $cli_bundle_issue, "Lost fine issue id matched to bundle issue"); >+ is( $procfee->issue_id, $cli_bundle_issue, "Processing fee issue id matched to bundle issue"); >+ $lostfine->delete(); >+ $procfee->delete(); > }; > > subtest "basic fields tests" => sub { >diff --git a/t/db_dependent/Circulation.t b/t/db_dependent/Circulation.t >index a62b6195a2..a2fc1cc834 100755 >--- a/t/db_dependent/Circulation.t >+++ b/t/db_dependent/Circulation.t >@@ -4298,6 +4298,25 @@ subtest 'AddReturn | recalls' => sub { > $recall1->set_cancelled; > }; > >+subtest 'AddReturn | bundles' => sub { >+ plan tests => 1; >+ >+ my $schema = Koha::Database->schema; >+ $schema->storage->txn_begin; >+ >+ my $patron1 = $builder->build_object({ class => 'Koha::Patrons' }); >+ my $host_item1 = $builder->build_sample_item; >+ my $bundle_item1 = $builder->build_sample_item; >+ $schema->resultset('ItemBundle') >+ ->create( >+ { host => $host_item1->itemnumber, item => $bundle_item1->itemnumber } ); >+ >+ my ( $doreturn, $messages, $iteminfo, $borrowerinfo ) = AddReturn( $bundle_item1->barcode, $bundle_item1->homebranch ); >+ is($messages->{InBundle}->id, $host_item1->id, 'AddReturn returns InBundle host item when item is part of a bundle'); >+ >+ $schema->storage->txn_rollback; >+}; >+ > subtest 'AddRenewal and AddIssuingCharge tests' => sub { > > plan tests => 13; >diff --git a/t/db_dependent/Koha/Item.t b/t/db_dependent/Koha/Item.t >index a61be9baa0..b13fe57e68 100755 >--- a/t/db_dependent/Koha/Item.t >+++ b/t/db_dependent/Koha/Item.t >@@ -20,7 +20,7 @@ > use Modern::Perl; > use utf8; > >-use Test::More tests => 17; >+use Test::More tests => 25; > use Test::Exception; > use Test::MockModule; > >@@ -41,6 +41,66 @@ use t::lib::Mocks; > my $schema = Koha::Database->new->schema; > my $builder = t::lib::TestBuilder->new; > >+subtest 'return_claims relationship' => sub { >+ plan tests => 3; >+ >+ $schema->storage->txn_begin; >+ >+ my $biblio = $builder->build_sample_biblio(); >+ my $item = $builder->build_sample_item({ >+ biblionumber => $biblio->biblionumber, >+ }); >+ my $return_claims = $item->return_claims; >+ is( ref($return_claims), 'Koha::Checkouts::ReturnClaims', 'return_claims returns a Koha::Checkouts::ReturnClaims object set' ); >+ is($item->return_claims->count, 0, "Empty Koha::Checkouts::ReturnClaims set returned if no return_claims"); >+ my $claim1 = $builder->build({ source => 'ReturnClaim', value => { itemnumber => $item->itemnumber }}); >+ my $claim2 = $builder->build({ source => 'ReturnClaim', value => { itemnumber => $item->itemnumber }}); >+ >+ is($item->return_claims()->count,2,"Two ReturnClaims found for item"); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'return_claim accessor' => sub { >+ plan tests => 5; >+ >+ $schema->storage->txn_begin; >+ >+ my $biblio = $builder->build_sample_biblio(); >+ my $item = $builder->build_sample_item({ >+ biblionumber => $biblio->biblionumber, >+ }); >+ my $return_claim = $item->return_claim; >+ is( $return_claim, undef, 'return_claim returned undefined if there are no claims for this item' ); >+ >+ my $claim1 = $builder->build_object( >+ { >+ class => 'Koha::Checkouts::ReturnClaims', >+ value => { itemnumber => $item->itemnumber, resolution => undef, created_on => dt_from_string()->subtract( minutes => 10 ) } >+ } >+ ); >+ my $claim2 = $builder->build_object( >+ { >+ class => 'Koha::Checkouts::ReturnClaims', >+ value => { itemnumber => $item->itemnumber, resolution => undef, created_on => dt_from_string()->subtract( minutes => 5 ) } >+ } >+ ); >+ >+ $return_claim = $item->return_claim; >+ is( ref($return_claim), 'Koha::Checkouts::ReturnClaim', 'return_claim returned a Koha::Checkouts::ReturnClaim object' ); >+ is( $return_claim->id, $claim2->id, 'return_claim returns the most recent unresolved claim'); >+ >+ $claim2->resolution('test')->store(); >+ $return_claim = $item->return_claim; >+ is( $return_claim->id, $claim1->id, 'return_claim returns the only unresolved claim'); >+ >+ $claim1->resolution('test')->store(); >+ $return_claim = $item->return_claim; >+ is( $return_claim, undef, 'return_claim returned undefined if there are no active claims for this item' ); >+ >+ $schema->storage->txn_rollback; >+}; >+ > subtest 'tracked_links relationship' => sub { > plan tests => 3; > >@@ -57,6 +117,139 @@ subtest 'tracked_links relationship' => sub { > is($item->tracked_links()->count,2,"Two tracked links found"); > }; > >+subtest 'is_bundle tests' => sub { >+ plan tests => 2; >+ >+ $schema->storage->txn_begin; >+ >+ my $item = $builder->build_sample_item(); >+ >+ my $is_bundle = $item->is_bundle; >+ is($is_bundle, 0, 'is_bundle returns 0 when there are no items attached'); >+ >+ my $item2 = $builder->build_sample_item(); >+ $schema->resultset('ItemBundle') >+ ->create( { host => $item->itemnumber, item => $item2->itemnumber } ); >+ >+ $is_bundle = $item->is_bundle; >+ is($is_bundle, 1, 'is_bundle returns 1 when there is at least one item attached'); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'in_bundle tests' => sub { >+ plan tests => 2; >+ >+ $schema->storage->txn_begin; >+ >+ my $item = $builder->build_sample_item(); >+ >+ my $in_bundle = $item->in_bundle; >+ is($in_bundle, 0, 'in_bundle returns 0 when the item is not in a bundle'); >+ >+ my $host_item = $builder->build_sample_item(); >+ $schema->resultset('ItemBundle') >+ ->create( { host => $host_item->itemnumber, item => $item->itemnumber } ); >+ >+ $in_bundle = $item->in_bundle; >+ is($in_bundle, 1, 'in_bundle returns 1 when the item is in a bundle'); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'bundle_items tests' => sub { >+ plan tests => 3; >+ >+ $schema->storage->txn_begin; >+ >+ my $host_item = $builder->build_sample_item(); >+ my $bundle_items = $host_item->bundle_items; >+ is( ref($bundle_items), 'Koha::Items', >+ 'bundle_items returns a Koha::Items object set' ); >+ is( $bundle_items->count, 0, >+ 'bundle_items set is empty when no items are bundled' ); >+ >+ my $bundle_item1 = $builder->build_sample_item(); >+ my $bundle_item2 = $builder->build_sample_item(); >+ my $bundle_item3 = $builder->build_sample_item(); >+ $schema->resultset('ItemBundle') >+ ->create( >+ { host => $host_item->itemnumber, item => $bundle_item1->itemnumber } ); >+ $schema->resultset('ItemBundle') >+ ->create( >+ { host => $host_item->itemnumber, item => $bundle_item2->itemnumber } ); >+ $schema->resultset('ItemBundle') >+ ->create( >+ { host => $host_item->itemnumber, item => $bundle_item3->itemnumber } ); >+ >+ $bundle_items = $host_item->bundle_items; >+ is( $bundle_items->count, 3, >+ 'bundle_items returns all the bundled items in the set' ); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'bundle_host tests' => sub { >+ plan tests => 3; >+ >+ $schema->storage->txn_begin; >+ >+ my $host_item = $builder->build_sample_item(); >+ my $bundle_item1 = $builder->build_sample_item(); >+ my $bundle_item2 = $builder->build_sample_item(); >+ $schema->resultset('ItemBundle') >+ ->create( >+ { host => $host_item->itemnumber, item => $bundle_item2->itemnumber } ); >+ >+ my $bundle_host = $bundle_item1->bundle_host; >+ is( $bundle_host, undef, 'bundle_host returns undefined when the item it not part of a bundle'); >+ $bundle_host = $bundle_item2->bundle_host; >+ is( ref($bundle_host), 'Koha::Item', 'bundle_host returns a Koha::Item object when the item is in a bundle'); >+ is( $bundle_host->id, $host_item->id, 'bundle_host returns the host item when called against an item in a bundle'); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'add_to_bundle tests' => sub { >+ plan tests => 3; >+ >+ $schema->storage->txn_begin; >+ >+ t::lib::Mocks::mock_preference( 'BundleNotLoanValue', 1 ); >+ >+ my $host_item = $builder->build_sample_item(); >+ my $bundle_item1 = $builder->build_sample_item(); >+ my $bundle_item2 = $builder->build_sample_item(); >+ >+ ok($host_item->add_to_bundle($bundle_item1), 'bundle_item1 added to bundle'); >+ is($bundle_item1->notforloan, 1, 'add_to_bundle sets notforloan to BundleNotLoanValue'); >+ >+ throws_ok { $host_item->add_to_bundle($bundle_item1) } >+ 'Koha::Exceptions::Object::DuplicateID', >+ 'Exception thrown if you try to add the same item twice'; >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'remove_from_bundle tests' => sub { >+ plan tests => 3; >+ >+ $schema->storage->txn_begin; >+ >+ my $host_item = $builder->build_sample_item(); >+ my $bundle_item1 = $builder->build_sample_item({ notforloan => 1 }); >+ $schema->resultset('ItemBundle') >+ ->create( >+ { host => $host_item->itemnumber, item => $bundle_item1->itemnumber } ); >+ >+ is($bundle_item1->remove_from_bundle(), 1, 'remove_from_bundle returns 1 when item is removed from a bundle'); >+ is($bundle_item1->notforloan, 0, 'remove_from_bundle resets notforloan to 0'); >+ $bundle_item1 = $bundle_item1->get_from_storage; >+ is($bundle_item1->remove_from_bundle(), 0, 'remove_from_bundle returns 0 when item is not in a bundle'); >+ >+ $schema->storage->txn_rollback; >+}; >+ > subtest 'hidden_in_opac() tests' => sub { > > plan tests => 4; >-- >2.32.0 (Apple Git-132)
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 28854
:
124319
|
124498
|
124616
|
125200
|
125201
|
125202
|
125203
|
125204
|
125205
|
125206
|
126103
|
126104
|
126105
|
126106
|
126107
|
126108
|
126109
|
126110
|
126148
|
126149
|
126150
|
126151
|
126152
|
126153
|
126154
|
126155
|
126166
|
126167
|
126168
|
126169
|
126170
|
126171
|
126172
|
126173
|
127498
|
127499
|
127500
|
127501
|
127502
|
127503
|
127504
|
127505
|
127659
|
127660
|
127661
|
127662
|
127663
|
127664
|
127665
|
127666
|
127829
|
127830
|
127831
|
127832
|
127833
|
127834
|
127835
|
127836
|
127837
|
127838
|
130313
|
130314
|
130315
|
130316
|
130317
|
130318
|
130319
|
130320
|
130321
|
130322
|
130323
|
130324
|
130325
|
130326
|
130327
|
130328
|
130329
|
130330
|
130331
|
130332
|
130456
|
132703
|
132704
|
132705
|
132706
|
132707
|
132708
|
132709
|
132710
|
132711
|
132712
|
132713
|
132714
|
132715
|
132716
|
132717
|
132718
|
132719
|
132720
|
132721
|
132722
|
132723
|
135019
|
135020
|
135021
|
135022
|
135023
|
135024
|
135025
|
135026
|
135027
|
135028
|
135029
|
135030
|
135031
|
135032
|
135033
|
135034
|
135035
|
135036
|
135037
|
136226
|
136227
|
136228
|
136229
|
136230
|
136231
|
136232
|
136233
|
136234
|
136235
|
136236
|
136237
|
136238
|
136239
|
136240
|
136241
|
136242
|
136243
|
136244
|
136249
|
136253
|
136254
|
136264
|
136265
|
136266
|
136267
|
136268
|
136269
|
136270
|
136271
|
136272
|
136273
|
136274
|
136275
|
136276
|
136277
|
136278
|
136279
|
136280
|
136281
|
136282
|
136283
|
136284
|
136285
|
136287
|
136340
|
136341
|
136342
|
136343
|
136344
|
136345
|
136346
|
136347
|
136348
|
136349
|
136350
|
136351
|
136352
|
136353
|
136354
|
136355
|
136356
|
136357
|
136358
|
136359
|
136360
|
136361
|
136362
|
136363
|
136364
|
136365
|
136366
|
136367
|
136368
|
136369
|
136370
|
136371
|
136498
|
136529
|
136530
|
136613
|
136614
|
136615
|
136616
|
136617
|
136618
|
136619
|
136620
|
136621
|
136622
|
136623
|
136624
|
136625
|
136626
|
136627
|
136628
|
136629
|
136630
|
136631
|
136632
|
136633
|
136634
|
136635
|
136636
|
136637
|
136638
|
136639
|
136640
|
136641
|
136642
|
136643
|
136644
|
136645
|
136646
|
136647
|
136648
|
136649
|
136650
|
136651
|
136652
|
136654
|
136655
|
136806
|
136809
|
136811
|
136814
|
136817
|
136821
|
136824
|
136826
|
136829
|
136832
|
136835
|
136838
|
136841
|
136843
|
136846
|
136849
|
136850
|
136851
|
136852
|
136853
|
136854
|
136855
|
136856
|
136857
|
136858
|
136859
|
136860
|
136861
|
136862
|
136863
|
136864
|
136865
|
136866
|
136867
|
136868
|
136869
|
136870
|
136871
|
136872
|
136873
|
136874
|
136875
|
136876
|
136877
|
136905
|
136906
|
136907
|
136908
|
136909
|
136910
|
136911
|
136912
|
136913
|
136914
|
136915
|
136916
|
136917
|
136918
|
136919
|
136920
|
136921
|
136922
|
136923
|
136924
|
136925
|
136926
|
136927
|
136928
|
136929
|
136930
|
136931
|
136932
|
136933
|
136934
|
136935
|
136936
|
136937
|
136938
|
136939
|
136940
|
136941
|
136942
|
136943
|
136944
|
136945
|
136946
|
136947
|
136949
|
136978
|
136979
|
136980
|
136981
|
136982
|
136985
|
136988
|
136989
|
136990
|
136991
|
136992
|
136993
|
136994
|
136995
|
136996
|
136997
|
136998
|
136999
|
137000
|
137001
|
137002
|
137003
|
137004
|
137005
|
137006
|
137007
|
137008
|
137009
|
137010
|
137011
|
137012
|
137013
|
137014
|
137015
|
137016
|
137017
|
137018
|
137019
|
137020
|
137021
|
137022
|
137461
|
137462
|
137463
|
137464
|
137465
|
137466
|
137467
|
137468
|
137469
|
137470
|
137471
|
137472
|
137473
|
137474
|
137475
|
137476
|
137477
|
137478
|
137479
|
137480
|
137481
|
137482
|
137483
|
137484
|
137485
|
137486
|
137487
|
137488
|
137489
|
137490
|
137491
|
137492
|
137493
|
137494
|
137495
|
137496
|
137497
|
137498
|
137499
|
137500
|
137501
|
137503
|
137506
|
137524
|
137525
|
137527
|
137528
|
137529
|
137530
|
137531
|
137532
|
137533
|
137534
|
137535
|
137536
|
137537
|
137538
|
137540
|
137541
|
137542
|
137543
|
137544
|
137545
|
137546
|
137547
|
137548
|
137549
|
137550
|
137551
|
137552
|
137553
|
137554
|
137555
|
137556
|
137557
|
137558
|
137559
|
137560
|
137561
|
137562
|
137563
|
137564
|
137565
|
137566
|
137567
|
137568
|
137569
|
137570
|
137571
|
137684
|
137685
|
139347