From df8fd1972dfb41cf6bb1f9b3b56b6ee6b0725c14 Mon Sep 17 00:00:00 2001 From: Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com> Date: Thu, 8 Dec 2016 18:32:04 +0100 Subject: [PATCH] Bug 11897: Add bulk of Stockrotation module. This commit replaces a set of about 20 commits into one large commit. This makes working with the module way easier from bugzilla. As it stands the patches on this bug provide a fully functioning stock rotation feature set for Koha. All previous patches that added functionality were signed off, so I'm retaining that flag. Signed-off-by: Kathleen Milne <kathleen.milne@cne-siar.gov.uk> --- Koha/Item.pm | 49 +- Koha/Library.pm | 46 ++ Koha/REST/V1/Stage.pm | 48 ++ Koha/StockRotationItem.pm | 273 +++++++++++ Koha/StockRotationItems.pm | 124 +++++ Koha/StockRotationRota.pm | 182 +++++++ Koha/StockRotationRotas.pm | 101 ++++ Koha/StockRotationStage.pm | 413 ++++++++++++++++ Koha/StockRotationStages.pm | 86 ++++ Koha/Util/StockRotation.pm | 247 ++++++++++ api/v1/swagger/paths.json | 3 + api/v1/swagger/paths/rotas.json | 79 ++++ catalogue/stockrotation.pl | 175 +++++++ koha-tmpl/intranet-tmpl/prog/css/staff-global.css | 128 +++++ .../prog/en/includes/biblio-view-menu.inc | 1 + .../prog/en/includes/stockrotation-toolbar.inc | 13 + .../intranet-tmpl/prog/en/includes/tools-menu.inc | 22 + .../en/modules/admin/preferences/circulation.pref | 2 +- .../prog/en/modules/catalogue/stockrotation.tt | 157 ++++++ .../prog/en/modules/tools/stockrotation.tt | 482 +++++++++++++++++++ .../prog/en/modules/tools/tools-home.tt | 7 + .../intranet-tmpl/prog/js/pages/stockrotation.js | 65 +++ misc/cronjobs/stockrotation.pl | 489 +++++++++++++++++++ t/db_dependent/Items.t | 61 +++ t/db_dependent/Koha/Libraries.t | 28 +- t/db_dependent/StockRotationItems.t | 393 +++++++++++++++ t/db_dependent/StockRotationRotas.t | 175 +++++++ t/db_dependent/StockRotationStages.t | 377 +++++++++++++++ t/db_dependent/api/v1/stockrotationstage.t | 172 +++++++ tools/stockrotation.pl | 526 +++++++++++++++++++++ 30 files changed, 4921 insertions(+), 3 deletions(-) create mode 100644 Koha/REST/V1/Stage.pm create mode 100644 Koha/StockRotationItem.pm create mode 100644 Koha/StockRotationItems.pm create mode 100644 Koha/StockRotationRota.pm create mode 100644 Koha/StockRotationRotas.pm create mode 100644 Koha/StockRotationStage.pm create mode 100644 Koha/StockRotationStages.pm create mode 100644 Koha/Util/StockRotation.pm create mode 100644 api/v1/swagger/paths/rotas.json create mode 100755 catalogue/stockrotation.pl create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/stockrotation-toolbar.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/stockrotation.tt create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/tools/stockrotation.tt create mode 100644 koha-tmpl/intranet-tmpl/prog/js/pages/stockrotation.js create mode 100755 misc/cronjobs/stockrotation.pl create mode 100644 t/db_dependent/StockRotationItems.t create mode 100644 t/db_dependent/StockRotationRotas.t create mode 100644 t/db_dependent/StockRotationStages.t create mode 100644 t/db_dependent/api/v1/stockrotationstage.t create mode 100755 tools/stockrotation.pl diff --git a/Koha/Item.pm b/Koha/Item.pm index bea05f8317..cba8d5adf6 100644 --- a/Koha/Item.pm +++ b/Koha/Item.pm @@ -30,6 +30,8 @@ use Koha::IssuingRules; use Koha::Item::Transfer; use Koha::Patrons; use Koha::Libraries; +use Koha::StockRotationItem; +use Koha::StockRotationRotas; use base qw(Koha::Object); @@ -235,7 +237,52 @@ sub current_holds { return Koha::Holds->_new_from_dbic($hold_rs); } -=head3 type +=head3 stockrotationitem + + my $sritem = Koha::Item->stockrotationitem; + +Returns the stock rotation item associated with the current item. + +=cut + +sub stockrotationitem { + my ( $self ) = @_; + my $rs = $self->_result->stockrotationitem; + return 0 if !$rs; + return Koha::StockRotationItem->_new_from_dbic( $rs ); +} + +=head3 add_to_rota + + my $item = $item->add_to_rota($rota_id); + +Add this item to the rota identified by $ROTA_ID, which means associating it +with the first stage of that rota. Should this item already be associated +with a rota, then we will move it to the new rota. + +=cut + +sub add_to_rota { + my ( $self, $rota_id ) = @_; + Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber); + return $self; +} + +=head3 biblio + + my $biblio = $item->biblio; + +Returns the biblio associated with the current item. + +=cut + +sub biblio { + my ( $self ) = @_; + my $rs = $self->_result->biblio; + return Koha::Biblio->_new_from_dbic( $rs ); +} + +=head3 _type =cut diff --git a/Koha/Library.pm b/Koha/Library.pm index 9e6d616036..7877174ec5 100644 --- a/Koha/Library.pm +++ b/Koha/Library.pm @@ -24,6 +24,7 @@ use Carp; use C4::Context; use Koha::Database; +use Koha::StockRotationStages; use base qw(Koha::Object); @@ -41,6 +42,51 @@ TODO: Ask the author to add a proper description =cut +sub get_categories { + my ( $self, $params ) = @_; + # TODO This should return Koha::LibraryCategories + return $self->{_result}->categorycodes( $params ); +} + +=head3 update_categories + +TODO: Ask the author to add a proper description + +=cut + +sub update_categories { + my ( $self, $categories ) = @_; + $self->_result->delete_related( 'branchrelations' ); + $self->add_to_categories( $categories ); +} + +=head3 add_to_categories + +TODO: Ask the author to add a proper description + +=cut + +sub add_to_categories { + my ( $self, $categories ) = @_; + for my $category ( @$categories ) { + $self->_result->add_to_categorycodes( $category->_result ); + } +} + +=head3 stockrotationstages + + my $stages = Koha::Library->stockrotationstages; + +Returns the stockrotation stages associated with this Library. + +=cut + +sub stockrotationstages { + my ( $self ) = @_; + my $rs = $self->_result->stockrotationstages; + return Koha::StockRotationStages->_new_from_dbic( $rs ); +} + =head3 get_effective_marcorgcode my $marcorgcode = Koha::Libraries->find( $library_id )->get_effective_marcorgcode(); diff --git a/Koha/REST/V1/Stage.pm b/Koha/REST/V1/Stage.pm new file mode 100644 index 0000000000..fa4ca7ad2b --- /dev/null +++ b/Koha/REST/V1/Stage.pm @@ -0,0 +1,48 @@ +package Koha::REST::V1::Stage; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; + +use Koha::StockRotationRotas; +use Koha::StockRotationStages; + +sub move { + my $c = shift->openapi->valid_input or return; + my $input = $c->validation->output; + + my $rota = Koha::StockRotationRotas->find( $input->{rota_id} ); + my $stage = Koha::StockRotationStages->find( $input->{stage_id} ); + + if ( $stage && $rota ) { + my $result = $stage->move_to( $input->{position} ); + return $c->render( openapi => {}, status => 200 ) if $result; + return $c->render( + openapi => { error => "Bad request - new position invalid" }, + status => 400 + ); + } + else { + return $c->render( + openapi => { error => "Not found - Invalid rota or stage ID" }, + status => 404 + ); + } +} + +1; diff --git a/Koha/StockRotationItem.pm b/Koha/StockRotationItem.pm new file mode 100644 index 0000000000..e451bb08db --- /dev/null +++ b/Koha/StockRotationItem.pm @@ -0,0 +1,273 @@ +package Koha::StockRotationItem; + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use DateTime; +use DateTime::Duration; +use Koha::Database; +use Koha::DateUtils qw/dt_from_string/; +use Koha::Item::Transfer; +use Koha::Item; +use Koha::StockRotationStage; + +use base qw(Koha::Object); + +=head1 NAME + +StockRotationItem - Koha StockRotationItem Object class + +=head1 SYNOPSIS + +StockRotationItem class used primarily by stockrotation .pls and the stock +rotation cron script. + +=head1 DESCRIPTION + +Standard Koha::Objects definitions, and additional methods. + +=head1 API + +=head2 Class Methods + +=cut + +=head3 _type + +=cut + +sub _type { + return 'Stockrotationitem'; +} + +=head3 itemnumber + + my $item = Koha::StockRotationItem->itemnumber; + +Returns the item associated with the current stock rotation item. + +=cut + +sub itemnumber { + my ( $self ) = @_; + my $rs = $self->_result->itemnumber; + return Koha::Item->_new_from_dbic( $rs ); +} + +=head3 stage + + my $stage = Koha::StockRotationItem->stage; + +Returns the stage associated with the current stock rotation item. + +=cut + +sub stage { + my ( $self ) = @_; + my $rs = $self->_result->stage; + return Koha::StockRotationStage->_new_from_dbic( $rs ); +} + +=head3 needs_repatriating + + 1|0 = $item->needs_repatriating; + +Return 1 if this item is currently not at the library it should be at +according to our stockrotation plan. + +=cut + +sub needs_repatriating { + my ( $self ) = @_; + my ( $item, $stage ) = ( $self->itemnumber, $self->stage ); + if ( $self->itemnumber->get_transfer ) { + return 0; # We're in transit. + } elsif ( $item->holdingbranch ne $stage->branchcode_id + || $item->homebranch ne $stage->branchcode_id ) { + return 1; # We're not where we should be. + } else { + return 0; # We're at home. + } +} + +=head3 needs_advancing + + 1|0 = $item->needs_advancing; + +Return 1 if this item is ready to be moved on to the next stage in its rota. + +=cut + +sub needs_advancing { + my ( $self ) = @_; + return 0 if $self->itemnumber->get_transfer; # intransfer: don't advance. + return 1 if $self->fresh; # Just on rota: advance. + my $completed = $self->itemnumber->_result->branchtransfers->search( + { 'comments' => "StockrotationAdvance" }, + { order_by => { -desc => 'datearrived' } } + ); + # Do maths on whether we need to be moved on. + if ( $completed->count ) { + my $arrival = dt_from_string( + $completed->next->datearrived, 'iso' + ); + my $duration = DateTime::Duration + ->new( days => $self->stage->duration ); + if ( $arrival + $duration le DateTime->now ) { + return 1; + } else { + return 0; + } + } else { + die "We have no historical branch transfer; this should not have happened!"; + } +} + +=head3 repatriate + + 1|0 = $sritem->repatriate + +Put this item into branch transfer with 'StockrotationCorrection' comment, so +that it may return to it's stage.branch to continue its rota as normal. + +=cut + +sub repatriate { + my ( $self, $msg ) = @_; + # Create the transfer. + my $transfer_stored = Koha::Item::Transfer->new({ + 'itemnumber' => $self->itemnumber_id, + 'frombranch' => $self->itemnumber->holdingbranch, + 'tobranch' => $self->stage->branchcode_id, + 'datesent' => DateTime->now, + 'comments' => $msg || "StockrotationRepatriation", + })->store; + $self->itemnumber->homebranch($self->stage->branchcode_id)->store; + return $transfer_stored; +} + +=head3 advance + + 1|0 = $sritem->advance; + +Put this item into branch transfer with 'StockrotationAdvance' comment, to +transfer it to the next stage in its rota. + +If this is the last stage in the rota and this rota is cyclical, we return to +the first stage. If it is not cyclical, then we delete this +StockRotationItem. + +If this item is 'indemand', and advance is invoked, we disable 'indemand' and +advance the item as per usual. + +=cut + +sub advance { + my ( $self ) = @_; + my $item = $self->itemnumber; + my $transfer = Koha::Item::Transfer->new({ + 'itemnumber' => $self->itemnumber_id, + 'frombranch' => $item->holdingbranch, + 'datesent' => DateTime->now, + 'comments' => "StockrotationAdvance" + }); + + if ( $self->indemand && !$self->fresh ) { + $self->indemand(0)->store; # De-activate indemand + $transfer->tobranch($self->stage->branchcode_id); + $transfer->datearrived(DateTime->now); + } else { + # Find and update our stage. + my $stage = $self->stage; + my $new_stage; + if ( $self->fresh ) { # Just added to rota + $new_stage = $self->stage->first_sibling || $self->stage; + $transfer->tobranch($new_stage->branchcode_id); + $transfer->datearrived(DateTime->now) # Already at first branch + if $item->holdingbranch eq $new_stage->branchcode_id; + $self->fresh(0)->store; # Reset fresh + } elsif ( !$stage->last_sibling ) { # Last stage + if ( $stage->rota->cyclical ) { # Cyclical rota? + # Revert to first stage. + $new_stage = $stage->first_sibling || $stage; + $transfer->tobranch($new_stage->branchcode_id); + $transfer->datearrived(DateTime->now); + } else { + $self->delete; # StockRotationItem is done. + return 1; + } + } else { + # Just advance. + $new_stage = $self->stage->next_sibling; + } + $self->stage_id($new_stage->stage_id)->store; # Set new stage + $item->homebranch($new_stage->branchcode_id)->store; # Update homebranch + $transfer->tobranch($new_stage->branchcode_id); # Send to new branch + } + + return $transfer->store; +} + +=head3 investigate + + my $report = $item->investigate; + +Return the base set of information, namely this individual item's report, for +generating stockrotation reports about this stockrotationitem. + +=cut + +sub investigate { + my ( $self ) = @_; + my $item_report = { + title => $self->itemnumber->_result->biblioitem + ->biblionumber->title, + author => $self->itemnumber->_result->biblioitem + ->biblionumber->author, + callnumber => $self->itemnumber->itemcallnumber, + location => $self->itemnumber->location, + onloan => $self->itemnumber->onloan, + barcode => $self->itemnumber->barcode, + itemnumber => $self->itemnumber_id, + branch => $self->itemnumber->_result->holdingbranch, + object => $self, + }; + my $reason; + if ( $self->fresh ) { + $reason = 'initiation'; + } elsif ( $self->needs_repatriating ) { + $reason = 'repatriation'; + } elsif ( $self->needs_advancing ) { + $reason = 'advancement'; + $reason = 'in-demand' if $self->indemand; + } else { + $reason = 'not-ready'; + } + $item_report->{reason} = $reason; + + return $item_report; +} + +1; + +=head1 AUTHOR + +Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com> + +=cut diff --git a/Koha/StockRotationItems.pm b/Koha/StockRotationItems.pm new file mode 100644 index 0000000000..8fde691ae4 --- /dev/null +++ b/Koha/StockRotationItems.pm @@ -0,0 +1,124 @@ +package Koha::StockRotationItems; + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Koha::Database; +use Koha::StockRotationItem; + +use base qw(Koha::Objects); + +=head1 NAME + +StockRotationItems - Koha StockRotationItems Object class + +=head1 SYNOPSIS + +StockRotationItems class used primarily by stockrotation .pls and the stock +rotation cron script. + +=head1 DESCRIPTION + +Standard Koha::Objects definitions, and additional methods. + +=head1 API + +=head2 Class Methods + +=cut + +=head3 _type + +=cut + +sub _type { + return 'Stockrotationitem'; +} + +sub object_class { + return 'Koha::StockRotationItem'; +} + +=head3 investigate + + my $report = $items->investigate; + +Return a stockrotation report about this set of stockrotationitems. + +In this part of the overall investigation process we split individual item +reports into appropriate action segments of our items report and increment +some counters. + +The report generated here will be used on the stage level to slot our item +reports into appropriate sections of the branched report. + +For details of intent and context of this procedure, please see +Koha::StockRotationRota->investigate. + +=cut + +sub investigate { + my ( $self ) = @_; + + my $items_report = { + items => [], + log => [], + initiable_items => [], + repatriable_items => [], + advanceable_items => [], + indemand_items => [], + actionable => 0, + stationary => 0, + }; + while ( my $item = $self->next ) { + my $report = $item->investigate; + if ( $report->{reason} eq 'initiation' ) { + $items_report->{initiable}++; + $items_report->{actionable}++; + push @{$items_report->{items}}, $report; + push @{$items_report->{initiable_items}}, $report; + } elsif ( $report->{reason} eq 'repatriation' ) { + $items_report->{repatriable}++; + $items_report->{actionable}++; + push @{$items_report->{items}}, $report; + push @{$items_report->{repatriable_items}}, $report; + } elsif ( $report->{reason} eq 'advancement' ) { + $items_report->{actionable}++; + push @{$items_report->{items}}, $report; + push @{$items_report->{advanceable_items}}, $report; + } elsif ( $report->{reason} eq 'in-demand' ) { + $items_report->{actionable}++; + push @{$items_report->{items}}, $report; + push @{$items_report->{indemand_items}}, $report; + } else { + $items_report->{stationary}++; + push @{$items_report->{log}}, $report; + } + } + + return $items_report; +} + +1; + +=head1 AUTHOR + +Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com> + +=cut diff --git a/Koha/StockRotationRota.pm b/Koha/StockRotationRota.pm new file mode 100644 index 0000000000..fdf52fccd9 --- /dev/null +++ b/Koha/StockRotationRota.pm @@ -0,0 +1,182 @@ +package Koha::StockRotationRota; + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Koha::Database; +use Koha::StockRotationStages; +use Koha::StockRotationItem; +use Koha::StockRotationItems; + +use base qw(Koha::Object); + +=head1 NAME + +StockRotationRota - Koha StockRotationRota Object class + +=head1 SYNOPSIS + +StockRotationRota class used primarily by stockrotation .pls and the stock +rotation cron script. + +=head1 DESCRIPTION + +Standard Koha::Objects definitions, and additional methods. + +=head1 API + +=head2 Class Methods + +=cut + +=head3 stockrotationstages + + my $stages = Koha::StockRotationRota->stockrotationstages; + +Returns the stages associated with the current rota. + +=cut + +sub stockrotationstages { + my ( $self ) = @_; + my $rs = $self->_result->stockrotationstages; + return Koha::StockRotationStages->_new_from_dbic( $rs ); +} + +=head3 add_item + + my $rota = $rota->add_item($itemnumber); + +Add item identified by $ITEMNUMBER to this rota, which means we associate it +with the first stage of this rota. Should the item already be associated with +a rota, move it from that rota to this rota. + +=cut + +sub add_item { + my ( $self, $itemnumber ) = @_; + my $sritem = Koha::StockRotationItems->find($itemnumber); + if ($sritem) { + $sritem->stage_id($self->first_stage->stage_id) + ->indemand(0)->fresh(1)->store; + } else { + $sritem = Koha::StockRotationItem->new({ + itemnumber_id => $itemnumber, + stage_id => $self->first_stage->stage_id, + indemand => 0, + fresh => 1, + })->store; + } + return $self; +} + +=head3 first_stage + + my $stage = $rota->first_stage; + +Return the first stage attached to this rota (the one that has an undefined +`stagebefore`). + +=cut + +sub first_stage { + my ( $self ) = @_; + my $guess = $self->stockrotationstages->next; + my $stage = $guess->first_sibling; + return ( $stage ) ? $stage : $guess; +} + +=head3 stockrotationitems + + my $items = $rota->stockrotationitems; + +Return all items associated with this rota via its stages. + +=cut + +sub stockrotationitems { + my ( $self ) = @_; + my $rs = Koha::StockRotationItems->search( + { 'stage.rota_id' => $self->rota_id }, { join => [ qw/stage/ ] } + ); + return $rs; +} + +=head3 investigate + + my $report = $rota->investigate($report_so_far); + +Aim here is to return $report augmented with content for this rota. We +delegate to $stage->investigate. + +The report will include some basic information and 2 primary reports: + +- per rota report in 'rotas'. This report is mainly used by admins to do check + & compare results. + +- branched report in 'branched'. This is the workhorse: emails to libraries + are compiled from these reports, and they will have the actionable work. + +Both reports are generated in stage based investigations; the rota report is +then glued into place at this stage. + +=cut + +sub investigate { + my ( $self, $report ) = @_; + my $count = $self->stockrotationitems->count; + $report->{sum_items} += $count; + + if ( $self->active ) { + $report->{rotas_active}++; + # stockrotationstages->investigate augments $report with the stage's + # content. This is how 'branched' slowly accumulates all items. + $report = $self->stockrotationstages->investigate($report); + # Add our rota report to the full report. + push @{$report->{rotas}}, { + name => $self->title, + id => $self->rota_id, + items => $report->{tmp_items} || [], + log => $report->{tmp_log} || [], + }; + delete $report->{tmp_items}; + delete $report->{tmp_log}; + } else { # Rota is not active. + $report->{rotas_inactive}++; + $report->{items_inactive} += $count; + } + + return $report; +} + +=head3 _type + +=cut + +sub _type { + return 'Stockrotationrota'; +} + +1; + +=head1 AUTHOR + +Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com> + +=cut diff --git a/Koha/StockRotationRotas.pm b/Koha/StockRotationRotas.pm new file mode 100644 index 0000000000..c308a9246b --- /dev/null +++ b/Koha/StockRotationRotas.pm @@ -0,0 +1,101 @@ +package Koha::StockRotationRotas; + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Koha::Database; +use Koha::StockRotationRota; + +use base qw(Koha::Objects); + +=head1 NAME + +StockRotationRotas - Koha StockRotationRotas Object class + +=head1 SYNOPSIS + +StockRotationRotas class used primarily by stockrotation .pls and the stock +rotation cron script. + +=head1 DESCRIPTION + +Standard Koha::Objects definitions, and additional methods. + +=head1 API + +=head2 Class Methods + +=cut + +=head3 investigate + + my $report = $rotas->investigate; + +Return a report detailing the current status and required actions for all +relevant items spread over rotas. + +See Koha::StockRotationRota->investigate for details. + +=cut + +sub investigate { + my ( $self ) = @_; + + my $report = { + actionable => 0, + advanceable => 0, + initiable => 0, + items_inactive => 0, + repatriable => 0, + rotas_active => 0, + rotas_inactive => 0, + stationary => 0, + sum_items => 0, + sum_rotas => $self->count, + branched => {}, + rotas => [], + items => [], + }; + + while ( my $rota = $self->next ) { + $report = $rota->investigate($report) + } + + return $report; +} + +=head3 _type + +=cut + +sub _type { + return 'Stockrotationrota'; +} + +sub object_class { + return 'Koha::StockRotationRota'; +} + +1; + +=head1 AUTHOR + +Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com> + +=cut diff --git a/Koha/StockRotationStage.pm b/Koha/StockRotationStage.pm new file mode 100644 index 0000000000..eadb71686e --- /dev/null +++ b/Koha/StockRotationStage.pm @@ -0,0 +1,413 @@ +package Koha::StockRotationStage; + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Koha::Database; +use Koha::Library; +use Koha::StockRotationRota; + +use base qw(Koha::Object); + +=head1 NAME + +StockRotationStage - Koha StockRotationStage Object class + +=head1 SYNOPSIS + +StockRotationStage class used primarily by stockrotation .pls and the stock +rotation cron script. + +=head1 DESCRIPTION + +Standard Koha::Objects definitions, and additional methods. + +=head1 API + +=head2 Class Methods + +=cut + +=head3 _type + +=cut + +sub _type { + return 'Stockrotationstage'; +} + +sub _relation { + my ( $self, $method, $type ) = @_; + return sub { + my $rs = $self->_result->$method; + return 0 if !$rs; + my $namespace = 'Koha::' . $type; + return $namespace->_new_from_dbic( $rs ); + } +} + +=head3 stockrotationitems + + my $stages = Koha::StockRotationStage->stockrotationitems; + +Returns the items associated with the current stage. + +=cut + +sub stockrotationitems { + my ( $self ) = @_; + return &{$self->_relation(qw/ stockrotationitems StockRotationItems /)}; +} + +=head3 branchcode + + my $branch = Koha::StockRotationStage->branchcode; + +Returns the branch associated with the current stage. + +=cut + +sub branchcode { + my ( $self ) = @_; + return &{$self->_relation(qw/ branchcode Library /)}; +} + +=head3 rota + + my $rota = Koha::StockRotationStage->rota; + +Returns the rota associated with the current stage. + +=cut + +sub rota { + my ( $self ) = @_; + return &{$self->_relation(qw/ rota StockRotationRota /)}; +} + +=head3 siblings + + my $siblings = $stage->siblings; + +Koha::Object wrapper around DBIx::Class::Ordered. + +=cut + +sub siblings { + my ( $self ) = @_; + return &{$self->_relation(qw/ siblings StockRotationStages /)}; +} + +=head3 next_siblings + + my $next_siblings = $stage->next_siblings; + +Koha::Object wrapper around DBIx::Class::Ordered. + +=cut + +sub next_siblings { + my ( $self ) = @_; + return &{$self->_relation(qw/ next_siblings StockRotationStages /)}; +} + +=head3 previous_siblings + + my $previous_siblings = $stage->previous_siblings; + +Koha::Object wrapper around DBIx::Class::Ordered. + +=cut + +sub previous_siblings { + my ( $self ) = @_; + return &{$self->_relation(qw/ previous_siblings StockRotationStages /)}; +} + +=head3 next_sibling + + my $next = $stage->next_sibling; + +Koha::Object wrapper around DBIx::Class::Ordered. + +=cut + +sub next_sibling { + my ( $self ) = @_; + return &{$self->_relation(qw/ next_sibling StockRotationStage /)}; +} + +=head3 previous_sibling + + my $previous = $stage->previous_sibling; + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub previous_sibling { + my ( $self ) = @_; + return &{$self->_relation(qw/ previous_sibling StockRotationStage /)}; +} + +=head3 first_sibling + + my $first = $stage->first_sibling; + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub first_sibling { + my ( $self ) = @_; + return &{$self->_relation(qw/ first_sibling StockRotationStage /)}; +} + +=head3 last_sibling + + my $last = $stage->last_sibling; + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub last_sibling { + my ( $self ) = @_; + return &{$self->_relation(qw/ last_sibling StockRotationStage /)}; +} + +=head3 move_previous + + 1|0 = $stage->move_previous; + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub move_previous { + my ( $self ) = @_; + return $self->_result->move_previous; +} + +=head3 move_next + + 1|0 = $stage->move_next; + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub move_next { + my ( $self ) = @_; + return $self->_result->move_next; +} + +=head3 move_first + + 1|0 = $stage->move_first; + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub move_first { + my ( $self ) = @_; + return $self->_result->move_first; +} + +=head3 move_last + + 1|0 = $stage->move_last; + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub move_last { + my ( $self ) = @_; + return $self->_result->move_last; +} + +=head3 move_to + + 1|0 = $stage->move_to($position); + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub move_to { + my ( $self, $position ) = @_; + return $self->_result->move_to($position) + if ( $position le $self->rota->stockrotationstages->count ); + return 0; +} + +=head3 move_to_group + + 1|0 = $stage->move_to_group($rota_id, [$position]); + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub move_to_group { + my ( $self, $rota_id, $position ) = @_; + return $self->_result->move_to_group($rota_id, $position); +} + +=head3 delete + + 1|0 = $stage->delete; + +Koha::Object Wrapper around DBIx::Class::Ordered. + +=cut + +sub delete { + my ( $self ) = @_; + return $self->_result->delete; +} + +=head3 investigate + + my $report = $stage->investigate($report_so_far); + +Return a stage based report. This report will mutate and augment the report +that is passed to it. It slots item reports into the branched and temporary +rota sections of the report. It also increments a number of counters. + +For details of intent and context of this procedure, please see +Koha::StockRotationRota->investigate. + +=cut + +sub investigate { + my ( $self, $report ) = @_; + my $new_stage = $self->next_sibling; + my $duration = $self->duration; + # Generate stage items report + my $items_report = $self->stockrotationitems->investigate; + + # Merge into general report + + ## Branched indexes + ### The branched indexes work as follows: + ### - They contain information about the relevant branch + ### - They contain an index of actionable items for that branch + ### - They contain an index of non-actionable items for that branch + + ### Items are assigned to a particular branched index as follows: + ### - 'advanceable' : assigned to branch of the current stage + ### (this should also be the current holding branch) + ### - 'log' items are always assigned to branch of current stage. + ### - 'indemand' : assigned to branch of current stage + ### (this should also be the current holding branch) + ### - 'initiable' : assigned to the current holding branch of item + ### - 'repatriable' : assigned to the current holding branch of item + + ### 'Advanceable', 'log', 'indemand': + + # Set up our stage branch info. + my $stagebranch = $self->_result->branchcode; + my $stagebranchcode = $stagebranch->branchcode; + + # Initiate our stage branch index if it does not yet exist. + if ( !$report->{branched}->{$stagebranchcode} ) { + $report->{branched}->{$stagebranchcode} = { + code => $stagebranchcode, + name => $stagebranch->branchname, + email => $stagebranch->branchemail, + phone => $stagebranch->branchphone, + items => [], + log => [], + }; + } + + push @{$report->{branched}->{$stagebranchcode}->{items}}, + @{$items_report->{advanceable_items}}; + push @{$report->{branched}->{$stagebranchcode}->{log}}, + @{$items_report->{log}}; + push @{$report->{branched}->{$stagebranchcode}->{items}}, + @{$items_report->{indemand_items}}; + + ### 'Initiable' & 'Repatriable' + foreach my $ireport (@{$items_report->{initiable_items}}) { + my $branch = $ireport->{branch}; + my $branchcode = $branch->branchcode; + if ( !$report->{branched}->{$branchcode} ) { + $report->{branched}->{$branchcode} = { + code => $branchcode, + name => $branch->branchname, + email => $branch->branchemail, + phone => $branch->branchphone, + items => [], + log => [], + }; + } + push @{$report->{branched}->{$branchcode}->{items}}, $ireport; + } + + foreach my $ireport (@{$items_report->{repatriable_items}}) { + my $branch = $ireport->{branch}; + my $branchcode = $branch->branchcode; + if ( !$report->{branched}->{$branchcode} ) { + $report->{branched}->{$branchcode} = { + code => $branchcode, + name => $branch->branchname, + email => $branch->branchemail, + phone => $branch->branchphone, + items => [], + log => [], + }; + } + push @{$report->{branched}->{$branchcode}->{items}}, $ireport; + } + + ## Per rota indexes + ### Per rota indexes are item reports pushed into the index for the + ### current rota. We don't know where that index is yet as we don't know + ### about the current rota. To resolve this we assign our items and log + ### to tmp indexes. They will be merged into the proper rota index at the + ### rota level. + push @{$report->{tmp_items}}, @{$items_report->{items}}; + push @{$report->{tmp_log}}, @{$items_report->{log}}; + + ## Collection of items + ### Finally we just add our collection of items to the full item index. + push @{$report->{items}}, @{$items_report->{items}}; + + ## Assemble counters + $report->{actionable} += $items_report->{actionable}; + $report->{indemand} += scalar @{$items_report->{indemand_items}}; + $report->{advanceable} += scalar @{$items_report->{advanceable_items}}; + $report->{initiable} += scalar @{$items_report->{initiable_items}}; + $report->{repatriable} += scalar @{$items_report->{repatriable_items}}; + $report->{stationary} += scalar @{$items_report->{log}}; + + return $report; +} + +1; + +=head1 AUTHOR + +Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com> + +=cut diff --git a/Koha/StockRotationStages.pm b/Koha/StockRotationStages.pm new file mode 100644 index 0000000000..00faaab28d --- /dev/null +++ b/Koha/StockRotationStages.pm @@ -0,0 +1,86 @@ +package Koha::StockRotationStages; + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Koha::Database; +use Koha::StockRotationStage; + +use base qw(Koha::Objects); + +=head1 NAME + +StockRotationStages - Koha StockRotationStages Object class + +=head1 SYNOPSIS + +StockRotationStages class used primarily by stockrotation .pls and the stock +rotation cron script. + +=head1 DESCRIPTION + +Standard Koha::Objects definitions, and additional methods. + +=head1 API + +=head2 Class Methods + +=cut + +=head3 investigate + + my $report = $stages->investigate($rota_so_far); + +Return a report detailing the current status and required actions for all +relevant items spread over the set of stages. + +For details of intent and context of this procedure, please see +Koha::StockRotationRota->investigate. + +=cut + +sub investigate { + my ( $self, $report ) = @_; + + while ( my $stage = $self->next ) { + $report = $stage->investigate($report); + } + + return $report; +} + +=head3 _type + +=cut + +sub _type { + return 'Stockrotationstage'; +} + +sub object_class { + return 'Koha::StockRotationStage'; +} + +1; + +=head1 AUTHOR + +Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com> + +=cut diff --git a/Koha/Util/StockRotation.pm b/Koha/Util/StockRotation.pm new file mode 100644 index 0000000000..13c78e5675 --- /dev/null +++ b/Koha/Util/StockRotation.pm @@ -0,0 +1,247 @@ +package Koha::Util::StockRotation; + +# Module contains subroutines used with Stock Rotation +# +# Copyright 2016 PTFS-Europe Ltd +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Koha::Items; +use Koha::StockRotationItems; +use Koha::Database; + +our ( @ISA, @EXPORT, @EXPORT_OK, %EXPORT_TAGS ); +BEGIN { + require Exporter; + @ISA = qw( Exporter ); + @EXPORT = qw( ); + @EXPORT_OK = qw( + get_branches + get_stages + toggle_indemand + remove_from_stage + get_barcodes_status + add_items_to_rota + move_to_next_stage + ); + %EXPORT_TAGS = ( ALL => [ @EXPORT_OK, @EXPORT ] ); +} + +=head1 NAME + +Koha::Util::StockRotation - utility class with routines for Stock Rotation + +=head1 FUNCTIONS + +=head2 get_branches + + returns all branches ordered by branchname as an array, each element + contains a hashref containing branch details + +=cut + +sub get_branches { + + return Koha::Libraries->search( + {}, + { order_by => ['branchname'] } + )->unblessed; + +} + +=head2 get_stages + + returns an arrayref of StockRotationStage objects representing + all stages for a passed rota + +=cut + +sub get_stages { + + my $rota = shift; + + my @out = (); + + if ($rota->stockrotationstages->count > 0) { + + push @out, $rota->first_stage->unblessed; + + push @out, @{$rota->first_stage->siblings->unblessed}; + + } + + return \@out; +} + +=head2 toggle_indemand + + given an item's ID & stage ID toggle that item's in_demand + status on that stage + +=cut + +sub toggle_indemand { + + my ($item_id, $stage_id) = @_; + + # Get the item object + my $item = Koha::StockRotationItems->find( + { + itemnumber_id => $item_id, + stage_id => $stage_id + } + ); + + # Toggle the item's indemand flag + my $new_indemand = ($item->indemand == 1) ? 0 : 1; + + $item->indemand($new_indemand)->store; + +} + +=head2 move_to_next_stage + + given an item's ID and stage ID, move it + to the next stage on the rota + +=cut + +sub move_to_next_stage { + + my ($item_id, $stage_id) = shift; + + # Get the item object + my $item = Koha::StockRotationItems->find( + { + itemnumber_id => $item_id, + stage_id => $stage_id + } + ); + + $item->advance; + +} + +=head2 remove_from_stage + + given an item's ID & stage ID, remove that item from that stage + +=cut + +sub remove_from_stage { + + my ($item_id, $stage_id) = @_; + + # Get the item object and delete it + Koha::StockRotationItems->find( + { + itemnumber_id => $item_id, + stage_id => $stage_id + } + )->delete; + +} + +=head2 get_barcodes_status + + take an arrayref of barcodes and a status hashref and populate it + +=cut + +sub get_barcodes_status { + + my ($rota_id, $barcodes, $status) = @_; + + # Get the items associated with these barcodes + my $items = Koha::Items->search( + { + barcode => { '-in' => $barcodes } + }, + { + prefetch => 'stockrotationitem' + } + ); + # Get an array of barcodes that were found + # Assign each barcode's status + my @found = (); + while (my $item = $items->next) { + + push @found, $item->barcode if $item->barcode; + + # Check if it's on a rota + my $on_rota = $item->stockrotationitem; + + # It is on a rota + if ($on_rota) { + + # Check if it's on this rota + if ($on_rota->stage->rota->rota_id == $rota_id) { + + # It's on this rota + push @{$status->{on_this}}, $item; + + } else { + + # It's on another rota + push @{$status->{on_other}}, $item; + + } + + } else { + + # Item is not on a rota + push @{$status->{ok}}, $item; + + } + + } + + # Create an array of barcodes supplied in the file that + # were not found in the catalogue + my %found_in_cat = map{ $_ => 1 } @found; + push @{$status->{not_found}}, grep( + !defined $found_in_cat{$_}, @{$barcodes} + ); + +} + +=head2 add_items_to_rota + + take an arrayref of Koha::Item objects and add them to the passed rota + +=cut + +sub add_items_to_rota { + + my ($rota_id, $items) = @_; + + foreach my $item(@{$items}) { + + $item->add_to_rota($rota_id); + + } + +} + +1; + +=head1 AUTHOR + +Andrew Isherwood <andrew.isherwood@ptfs-europe.com> + +=cut diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json index f3e843e6c3..c210c90959 100644 --- a/api/v1/swagger/paths.json +++ b/api/v1/swagger/paths.json @@ -25,5 +25,8 @@ }, "/illrequests": { "$ref": "paths/illrequests.json#/~1illrequests" + }, + "/rotas/{rota_id}/stages/{stage_id}/position": { + "$ref": "paths/rotas.json#/~1rotas~1{rota_id}~1stages~1{stage_id}~1position" } } diff --git a/api/v1/swagger/paths/rotas.json b/api/v1/swagger/paths/rotas.json new file mode 100644 index 0000000000..0cdda30317 --- /dev/null +++ b/api/v1/swagger/paths/rotas.json @@ -0,0 +1,79 @@ +{ + "/rotas/{rota_id}/stages/{stage_id}/position": { + "put": { + "x-mojo-to": "Stage#move", + "operationId": "moveStage", + "tags": ["rotas"], + "parameters": [{ + "name": "rota_id", + "in": "path", + "required": true, + "description": "A rotas ID", + "type": "integer" + }, { + "name": "stage_id", + "in": "path", + "required": true, + "description": "A stages ID", + "type": "integer" + }, { + "name": "position", + "in": "body", + "required": true, + "description": "A stages position in the rota", + "schema": { + "type": "integer" + } + }], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "401": { + "description": "Authentication required", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "403": { + "description": "Access forbidden", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "404": { + "description": "Position not found", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "503": { + "description": "Under maintenance", + "schema": { + "$ref": "../definitions.json#/error" + } + } + }, + "x-koha-authorization": { + "permissions": { + "borrowers": "1" + } + } + } + } +} diff --git a/catalogue/stockrotation.pl b/catalogue/stockrotation.pl new file mode 100755 index 0000000000..ff63b8a270 --- /dev/null +++ b/catalogue/stockrotation.pl @@ -0,0 +1,175 @@ +#!/usr/bin/perl + +# Copyright 2016 PTFS-Europe Ltd +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +=head1 stockrotation.pl + + Script to manage item assignments to stock rotation rotas. Including their + assiciated stages + +=cut + +use Modern::Perl; +use CGI; + +use C4::Auth; +use C4::Output; +use C4::Search; + +use Koha::Biblio; +use Koha::Item; +use Koha::StockRotationRotas; +use Koha::StockRotationStages; +use Koha::Util::StockRotation qw(:ALL); + +my $input = new CGI; + +unless (C4::Context->preference('StockRotation')) { + # redirect to Intranet home if self-check is not enabled + print $input->redirect("/cgi-bin/koha/mainpage.pl"); + exit; +} + +my %params = $input->Vars(); + +my $op = $params{op}; + +my $biblionumber = $input->param('biblionumber'); + +my ($template, $loggedinuser, $cookie) = get_template_and_user( + { + template_name => 'catalogue/stockrotation.tt', + query => $input, + type => 'intranet', + authnotrequired => 0, + flagsrequired => { catalogue => 1 } + } +); + +if (!defined $op) { + + # List all items along with their associated rotas + my $biblio = Koha::Biblios->find($biblionumber); + + my $items = $biblio->items; + + # Get only rotas with stages + my $rotas = Koha::StockRotationRotas->search( + { + 'stockrotationstages.stage_id' => { '!=', undef } + }, + { + join => 'stockrotationstages', + collapse => 1 + } + ); + + # Construct a model to pass to the view + my @item_data = (); + + while (my $item = $items->next) { + + my $item_hashref = { + bib_item => $item + }; + + my $stockrotationitem = $item->stockrotationitem; + + # If this item is on a rota + if ($stockrotationitem != 0) { + + # This item's rota + my $rota = $stockrotationitem->stage->rota; + + # This rota's stages + my $stages = get_stages($rota); + + $item_hashref->{rota} = $rota; + + $item_hashref->{stockrotationitem} = $stockrotationitem; + + $item_hashref->{stages} = $stages; + + } + + push @item_data, $item_hashref; + + } + + $template->param( + no_op_set => 1, + rotas => $rotas, + items => \@item_data, + branches => get_branches(), + biblio => $biblio, + biblionumber => $biblio->biblionumber, + stockrotationview => 1, + C4::Search::enabled_staff_search_views + ); + +} elsif ($op eq "toggle_in_demand") { + + # Toggle in demand + toggle_indemand($params{item_id}, $params{stage_id}); + + # Return to items list + print $input->redirect("?biblionumber=$biblionumber"); + +} elsif ($op eq "remove_item_from_stage") { + + # Remove from the stage + remove_from_stage($params{item_id}, $params{stage_id}); + + # Return to items list + print $input->redirect("?biblionumber=$biblionumber"); + +} elsif ($op eq "move_to_next_stage") { + + move_to_next_stage($params{item_id}, $params{stage_id}); + + # Return to items list + print $input->redirect("?biblionumber=" . $params{biblionumber}); + +} elsif ($op eq "add_item_to_rota") { + + my $item = Koha::Items->find($params{item_id}); + + $item->add_to_rota($params{rota_id}); + + print $input->redirect("?biblionumber=" . $params{biblionumber}); + +} elsif ($op eq "confirm_remove_from_rota") { + + $template->param( + op => $params{op}, + stage_id => $params{stage_id}, + item_id => $params{item_id}, + biblionumber => $params{biblionumber}, + stockrotationview => 1, + C4::Search::enabled_staff_search_views + ); + +} + +output_html_with_http_headers $input, $cookie, $template->output; + +=head1 AUTHOR + +Andrew Isherwood <andrew.isherwood@ptfs-europe.com> + +=cut diff --git a/koha-tmpl/intranet-tmpl/prog/css/staff-global.css b/koha-tmpl/intranet-tmpl/prog/css/staff-global.css index 32a614bd70..c4ba0262f7 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/staff-global.css +++ b/koha-tmpl/intranet-tmpl/prog/css/staff-global.css @@ -3218,3 +3218,131 @@ span.name { font-weight: bold; font-style: italic; } + +#stockrotation h3 { + margin: 30px 0 10px 0; +} + +#stockrotation .dialog h3 { + margin: 10px 0; +} + +#stockrotation .dialog { + margin-bottom: 20px; +} + +#stockrotation .highlight_stage, +#catalog_stockrotation .highlight_stage { + font-weight: bold; +} + +#stockrotation #rota_form textarea { + width: 300px; + height: 100px; +} + +#stockrotation #rota_form #name { + width: 300px; +} + +#stockrotation #rota_form fieldset, +#stockrotation #stage_form fieldset, +#stockrotation #add_rota_item_form fieldset { + width: auto; +} + +#stockrotation .dialog.alert ul { + margin: 20px 0; +} + +#stockrotation .dialog.alert li { + list-style-type: none; +} + +#catalog_stockrotation .item_select_rota { + vertical-align: middle; +} + +#catalog_stockrotation h1 { + margin-bottom: 20px; +} + +#stockrotation td.actions, +#catalog_stockrotation td.actions { + vertical-align: middle; +} + +#stockrotation .stage, +#catalog_stockrotation .stage { + display: inline-block; + padding: 5px 7px; + margin: 3px 0 3px 0; + border-radius: 5px; + background-color: rgba(0,0,0,0.1); +} + +#stage_list_headings { + font-weight: bold; +} + +#stage_list_headings span { + padding: 3px; +} + +#manage_stages ul { + padding-left: 0; +} + +#manage_stages li { + list-style: none; + margin-bottom: 5px; +} + +#manage_stages li span { + padding: 6px 3px; +} + +#manage_stages .stagename { + width: 15em; + display: inline-block; +} + +#manage_stages .stageduration { + width: 10em; + display: inline-block; +} + +#manage_stages .stageactions { + display: inline-block; +} + +#manage_stages li:nth-child(odd) { + background-color : #F3F3F3; +} + +#manage_stages .drag_handle { + margin-right: 6px; + cursor: move; +} + +#manage_stages .drag_placeholder { + height: 2em; + border: 1px dotted #aaa; +} + +#manage_stages h3 { + display: inline-block; +} + +#manage_stages #ajax_status { + display: inline-block; + border: 1px solid #bcbcbc; + border-radius: 5px; + padding: 5px; + margin-left: 10px; + background: #f3f3f3; +} + +#manage_stages #manage_stages_help { + margin: 20px 0; +} diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/biblio-view-menu.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/biblio-view-menu.inc index 95c0ee4640..b32f60939e 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/biblio-view-menu.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/biblio-view-menu.inc @@ -40,6 +40,7 @@ [% IF ( issuehistoryview ) %]<li class="active">[% ELSE %]<li>[% END %] <a href="/cgi-bin/koha/catalogue/issuehistory.pl?biblionumber=[% biblio_object_id | url %]" >Checkout history</a></li> [% IF ( CAN_user_tools_view_system_logs ) %][% IF ( logview ) %]<li class="active">[% ELSE %]<li>[% END %]<a href="/cgi-bin/koha/tools/viewlog.pl?do_it=1&modules=CATALOGUING&action=MODIFY&object=[% biblio_object_id | url %]">Modification log</a> </li>[% END %] +[% IF ( CAN_user_stockrotation_can_add_items_rotas && Koha.Preference('StockRotation') ) %][% IF ( stockrotationview ) %]<li class="active">[% ELSE %]<li>[% END %]<a href="/cgi-bin/koha/catalogue/stockrotation.pl?biblionumber=[% biblio_object_id %]">Rota</a> </li>[% END %] </ul> </div> diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/stockrotation-toolbar.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/stockrotation-toolbar.inc new file mode 100644 index 0000000000..c8e72e9dd6 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/stockrotation-toolbar.inc @@ -0,0 +1,13 @@ +[% USE Koha %] +<div id="toolbar" class="btn-toolbar"> + [% IF no_op_set %] + <a id="addrota" class="btn btn-default btn-sm" href="/cgi-bin/koha/tools/stockrotation.pl?op=create_edit_rota"><i class="fa fa-plus"></i> New rota</a> + [% END %] + [% IF op == 'manage_stages' %] + <a id="addstage" class="btn btn-default btn-sm" href="/cgi-bin/koha/tools/stockrotation.pl?op=create_edit_stage&rota_id=[% rota_id %]"><i class="fa fa-plus"></i> New stage</a> + [% END %] + [% IF op == 'manage_items' %] + <a id="addrota" class="btn btn-default btn-sm" href="/cgi-bin/koha/tools/stockrotation.pl?op=create_edit_rota"><i class="fa fa-plus"></i> New rota</a> + <a id="editrota" class="btn btn-default btn-sm" href="/cgi-bin/koha/tools/stockrotation.pl?op=create_edit_rota&rota_id=[% rota_id %]"><i class="fa fa-pencil"></i> Edit rota</a> + [% END %] +</div> diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/tools-menu.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/tools-menu.inc index 50e52f73c0..d4a6ad2b1c 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/tools-menu.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/tools-menu.inc @@ -1,3 +1,22 @@ +[% USE Koha %] + +<script type="text/javascript">//<![CDATA[ + $(document).ready(function() { + var path = location.pathname.substring(1); + if (path.indexOf("labels") >= 0 && path.indexOf("spine") < 0 ) { + $('#navmenulist a[href$="/cgi-bin/koha/labels/label-home.pl"]').css('font-weight','bold'); + } else if (path.indexOf("patroncards") >= 0 ) { + $('#navmenulist a[href$="/cgi-bin/koha/patroncards/home.pl"]').css('font-weight','bold'); + } else if (path.indexOf("patron_lists") >= 0 ) { + $('#navmenulist a[href$="/cgi-bin/koha/patron_lists/lists.pl"]').css('font-weight','bold'); + } else if ((path+location.search).indexOf("batchMod.pl?del=1") >= 0 ) { + $('#navmenulist a[href$="/cgi-bin/koha/tools/batchMod.pl?del=1"]').css('font-weight','bold'); + } else { + $('#navmenulist a[href$="/' + path + '"]').css('font-weight','bold'); + } + }); +//]]> +</script> <div id="navmenu"> <div id="navmenulist"> <ul> @@ -38,6 +57,9 @@ [% IF ( CAN_user_tools_batch_upload_patron_images ) %] <li><a href="/cgi-bin/koha/tools/picture-upload.pl">Upload patron images</a></li> [% END %] + [% IF ( CAN_user_stockrotation_can_edit_rotas && Koha.Preference('StockRotation') ) %] + <li><a href="/cgi-bin/koha/tools/stockrotation.pl">Stock rotation</a></li> + [% END %] </ul> <h5>Catalog</h5> <ul> 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 aba5c0ab0a..ca79155d74 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 @@ -901,7 +901,7 @@ Circulation: yes: Enable no: Disable - "housebound module" - Stockrotation module: + Stock Rotation module: - - pref: StockRotation choices: diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/stockrotation.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/stockrotation.tt new file mode 100644 index 0000000000..8d9b011d87 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/stockrotation.tt @@ -0,0 +1,157 @@ +[% USE Koha %] +[% USE Branches %] +[% INCLUDE 'doc-head-open.inc' %] +<title>Koha › Catalog › Stock rotation details for [% biblio.title %]</title> +[% INCLUDE 'doc-head-close.inc' %] +[% INCLUDE 'browser-strings.inc' %] +<!--[if lt IE 9]> +<script type="text/javascript" src="[% interface %]/lib/shims/json2.min.js"></script> +<![endif]--> +<script type="text/javascript" src="[% interface %]/js/browser.js"></script> +<script type="text/javascript"> +//<![CDATA[ + var browser = KOHA.browser('[% searchid %]', parseInt('[% biblionumber %]', 10)); + browser.show(); +//]]> +</script> +</head> +<body id="catalog_stockrotation" class="catalog"> +[% USE KohaDates %] +[% INCLUDE 'header.inc' %] +[% INCLUDE 'cat-search.inc' %] + +<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> › <a href="/cgi-bin/koha/catalogue/search.pl">Catalog</a> › Stock rotation details for <i>[% biblio.title | html %][% FOREACH subtitle IN biblio.subtitles %][% subtitle.subfield %][% END %]</i></div> + +<div id="doc3" class="yui-t2"> + + <div id="bd"> + <div id="yui-main"> + <div class="yui-b"> + +<div id="catalogue_detail_biblio"> + + [% IF no_op_set %] + <h1 class="title">Stock rotation details for [% biblio.title | html %]</h1> + [% IF rotas.count > 0 && items.size > 0 %] + + <table class="items_table dataTable no-footer" role="grid"> + <thead> + <tr> + <th>Barcode</th> + <th>Shelfmark</th> + <th>Rota</th> + <th>Rota status</th> + <th>In transit</th> + <th>Stages & duration in days<br>(current stage highlighted)</th> + <th> </th> + </tr> + </thead> + <tbody> + [% FOREACH item IN items %] + <tr> + <td>[% item.bib_item.barcode %]</td> + <td>[% item.bib_item.itemcallnumber %]</td> + <td> + [% item.rota.title %] + </td> + <td> + [% IF item.rota %] + [% IF !item.rota.active %] + <span class="highlighted-row"> + [% END %] + [% item.rota.active ? 'Active' : 'Inactive' %] + + [% IF !item.rota.active %] + </span> + [% END %] + [% END %] + </td> + <td> + [% item.bib_item.get_transfer ? 'Yes' : 'No' %] + </td> + <td> + [% FOREACH this_stage IN item.stages %] + [% IF this_stage.stage_id == item.stockrotationitem.stage.stage_id %] + <span class="stage highlight_stage"> + [% ELSE %] + <span class="stage"> + [% END %] + [% Branches.GetName(this_stage.branchcode_id) %] ([% this_stage.duration %]) + </span> + » + [% END %] + [% IF item.stages.size > 0 %] + <span class="stage">[% item.rota.cyclical ? 'START' : 'END' %]</span> + [% END %] + </td> + <td class="actions"> + [% IF item.stockrotationitem %] + [% in_transit = item.bib_item.get_transfer %] + [% IF !in_transit && item.stages.size > 1 %] + <a class="btn btn-default btn-xs" href="?op=move_to_next_stage&stage_id=[% item.stockrotationitem.stage.stage_id %]&item_id=[% item.bib_item.id %]&biblionumber=[% biblionumber %]"> + [% ELSE %] + <a class="btn btn-default btn-xs" disabled> + [% END %] + <i class="fa fa-arrow-right"></i> + Move to next stage + </a> + [% IF !in_transit %] + <a class="btn btn-default btn-xs" href="?op=toggle_in_demand&stage_id=[% item.stockrotationitem.stage.stage_id %]&item_id=[% item.bib_item.id %]&biblionumber=[% biblionumber %]"> + [% ELSE %] + <a class="btn btn-default btn-xs" disabled> + [% END %] + <i class="fa fa-fire"></i> + [% item.stockrotationitem.indemand ? 'Remove "In demand"' : 'Add "In demand"' %] + </a> + [% IF !in_transit %] + <a class="btn btn-default btn-xs" href="?op=confirm_remove_from_rota&stage_id=[% item.stockrotationitem.stage.stage_id %]&item_id=[% item.bib_item.id %]&biblionumber=[% biblionumber %]"> + [% ELSE %] + <a class="btn btn-default btn-xs" disabled> + [% END %] + <i class="fa fa-trash"></i> + Remove from rota + </a> + [% ELSE %] + <form class="rota_select_form" method="post" enctype="multipart/form-data"> + <select class="item_select_rota" name="rota_id"> + [% FOREACH rota IN rotas %] + <option value="[% rota.rota_id %]">[% rota.title %]</option> + [% END %] + </select> + <button class="btn btn-default btn-xs" type="submit"><i class="fa fa-plus"></i> Add to rota</button> + <input type="hidden" name="op" value="add_item_to_rota"></input> + <input type="hidden" name="item_id" value="[% item.bib_item.id %]"></input> + <input type="hidden" name="biblionumber" value="[% biblionumber %]"></input> + </form> + [% END %] + </td> + </tr> + [% END %] + </tbody> + </table> + [% END %] + [% IF !items || items.size == 0 %] + <h1>No physical items for this record</h1> + [% END %] + [% IF !rotas || rotas.count == 0 %] + <h1>There are no rotas with stages assigned</h1> + [% END %] + [% ELSIF op == 'confirm_remove_from_rota' %] + <div class="dialog alert"> + <h3>Are you sure you want to remove this item from it's rota?</h3> + <p> + <a class="btn btn-default btn-xs approve" href="?op=remove_item_from_stage&stage_id=[% stage_id %]&item_id=[% item_id %]&biblionumber=[% biblionumber %]"><i class="fa fa-fw fa-check"></i>Yes</a> + <a class="btn btn-default btn-xs deny" href="?biblionumber=[% biblionumber %]"><i class="fa fa-fw fa-remove"></i>No</a> + </p> + </div> + [% END %] + +</div> + +</div> +</div> +<div class="yui-b"> +[% INCLUDE 'biblio-view-menu.inc' %] +</div> +</div> +[% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/stockrotation.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/stockrotation.tt new file mode 100644 index 0000000000..e9cf827fad --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/stockrotation.tt @@ -0,0 +1,482 @@ +[% USE Koha %] +[% USE Branches %] +[% USE KohaDates %] +[% INCLUDE 'doc-head-open.inc' %] +<title>Koha › Stock rotation</title> +[% INCLUDE 'doc-head-close.inc' %] +<link rel="stylesheet" type="text/css" href="[% interface %]/[% theme %]/css/datatables.css" /> +[% INCLUDE 'datatables.inc' %] +<script type="text/javascript" src="[% interface %]/[% theme %]/js/pages/stockrotation.js"></script> +<script type="text/javascript"> +//<![CDATA[ + $(document).ready(function() { + $('#sr_manage_items').dataTable($.extend(true, {}, dataTablesDefaults, { + "autoWidth": false, + "aoColumnDefs": [ + { "bSortable": false, "bSearchable": false, 'aTargets': [ 'NoSort' ] }, + { "bSortable": true, "bSearchable": false, 'aTargets': [ 'NoSearch' ] } + ], + "sPaginationType": "four_button" + })); + }); +//]]> +</script> +</head> + +<body> +[% INCLUDE 'header.inc' %] +[% INCLUDE 'patron-search.inc' %] + +<div id="breadcrumbs"> + <a href="/cgi-bin/koha/mainpage.pl">Home</a> + › <a href="/cgi-bin/koha/tools/tools-home.pl">Tools</a> + +[% IF no_op_set %] + › Stock rotation +[% ELSE %] + › <a href="/cgi-bin/koha/tools/stockrotation.pl">Stock rotation</a> +[% END %] + +[% IF (op == 'create_edit_rota' && rota.rota_id) %] + › Edit rota +[% ELSIF (op == 'create_edit_rota' && !rota.rota_id) %] + › Create rota +[% ELSIF (op == 'manage_stages') %] + › Manage stages +[% ELSIF (op == 'create_edit_stage' && stage.id) %] + <a href="?op=manage_stages&rota_id=[% rota_id %]">› Manage stages</a> + › Edit stage +[% ELSIF (op == 'create_edit_stage' && !stage.id) %] + <a href="?op=manage_stages&rota_id=[% rota_id %]">› Manage stages</a> + › Create stage +[% ELSIF (op == 'manage_items') %] + › Manage items +[% END %] + +</div> + +<div id="doc3" class="yui-t2"> + <div id="bd"> + <div id="yui-main"> + <div id="stockrotation" class="yui-b"> + + <h1>Stock rotation</h1> + + [% IF no_op_set %] + + [% INCLUDE 'stockrotation-toolbar.inc' %] + + [% IF existing_rotas.size > 0 %] + <h3>Manage existing rotas</h3> + <table class="rotas_table" role="grid"> + <thead> + <th>Name</th> + <th>Cyclical</th> + <th>Active</th> + <th>Description</th> + <th>Number of items</th> + <th> </th> + </thead> + <tbody> + [% FOREACH rota IN existing_rotas %] + <tr> + <td>[% rota.title %]</td> + <td>[% rota.cyclical ? 'Yes' : 'No'%]</td> + <td>[% rota.active ? 'Yes' : 'No'%]</td> + <td>[% rota.description %]</td> + <td>[% rota.stockrotationitems.count %]</td> + <td class="actions"> + <a class="btn btn-default btn-xs" href="?op=create_edit_rota&rota_id=[% rota.rota_id %]"> + <i class="fa fa-pencil"></i> + Edit + </a> + <div class="btn-group" role="group"> + <button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> + Manage + <i class="fa fa-caret-down"></i> + </button> + <ul class="dropdown-menu"> + <li><a href="?op=manage_stages&rota_id=[% rota.rota_id %]">Stages</a></li> + [% IF CAN_user_stockrotation_can_add_items_rotas && rota.stockrotationstages.count > 0 %] + <li><a href="?op=manage_items&rota_id=[% rota.rota_id %]">Items</a></li> + [% END %] + </ul> + </div> + <a class="btn btn-default btn-xs" href="?op=toggle_rota&rota_id=[% rota.rota_id %]"> + <i class="fa fa-power-off"></i> + [% IF !rota.active %] + Activate + [% ELSE %] + Deactivate + [% END %] + </a> + </td> + </tr> + [% END %] + </tbody> + </table> + [% END %] + + [% ELSIF (op == 'create_edit_rota') %] + + [% IF rota.rota_id %] + <h3>Edit "[% rota.title %]"</h3> + [% ELSE %] + <h3>Create new rota</h3> + [% END %] + + [% IF error == 'invalid_form' %] + <div class="dialog alert"> + <h3>There was a problem with your form submission</h3> + </div> + [% END %] + + <form id="rota_form" method="post" enctype="multipart/form-data" class="validated"> + <fieldset class="rows"> + <ol> + <li> + <label class="required" for="title">Name:</label> + <input type="text" id="title" name="title" value="[% rota.title %]" required="required" placeholder="Rota name"> + <span class="required">Required</span> + </li> + <li> + <label for="cyclical">Cyclical:</label> + <select name="cyclical" id="cyclical"> + [% IF rota.cyclical %] + <option value="1" selected>Yes</option> + <option value="0">No</option> + [% ELSE %] + <option value="1">Yes</option> + <option value="0" selected>No</option> + [% END %] + </select> + </li> + <li> + <label for="active">Active:</label> + <select name="active" id="active"> + [% IF rota.active %] + <option value="1" selected>Yes</option> + <option value="0">No</option> + [% ELSE %] + <option value="1">Yes</option> + <option value="0" selected>No</option> + [% END %] + </select> + </li> + <li> + <label for="description">Description:</label> + <textarea id="description" name="description" placeholder="Rota description">[% rota.description %]</textarea> + </li> + </ol> + </fieldset> + <fieldset class="action"> + <input type="submit" value="Save"> + <a href="/cgi-bin/koha/tools/stockrotation.pl" class="cancel">Cancel</a> + </fieldset> + [% IF rota.rota_id %] + <input type="hidden" name="id" value="[% rota.rota_id %]"> + [% END %] + <input type="hidden" name="op" value="process_rota"> + </form> + + [% ELSIF (op == 'manage_stages') %] + + [% INCLUDE 'stockrotation-toolbar.inc' %] + + [% IF existing_stages.size > 0 %] + <div id="manage_stages"> + <h3>Manage stages</h3> + <div id="ajax_status" + data-saving-msg="Saving changes..." + data-success-msg="" + data-failed-msg="Error: "> + <span id="ajax_saving_msg"></span> + <i id="ajax_saving_icon" class="fa fa-spinner fa-spin"></i> + <i id="ajax_success_icon" class="fa fa-check"></i> + <i id="ajax_failed_icon" class="fa fa-times"></i> + <span id="ajax_success_msg"></span> + <span id="ajax_failed_msg"></span> + </div> + <div id="manage_stages_help"> + Stages can be re-ordered by using the <i class="drag_handle fa fa-lg fa-bars"></i>handle to drag and drop them to their new position + </div> + <div id="stage_list_headings"> + <span class="stagename">Branch</span> + <span class="stageduration">Duration (days)</span> + </div> + <ul id="sortable_stages" data-rota-id="[% rota.rota_id %]"> + [% FOREACH stage IN existing_stages %] + <li id="stage_[% stage.stage_id %]"> + <span data-toggle="tooltip" title="Drag and drop to move this stage to another position" data-placement="right" class="stagename"> + [% IF existing_stages.size > 1 %] + <i class="drag_handle fa fa-lg fa-bars"></i> + [% END %] + [% Branches.GetName(stage.branchcode_id) %] + </span> + <span class="stageduration">[% stage.duration %]</span> + <span class="stageactions"> + <a class="btn btn-default btn-xs" href="?op=create_edit_stage&stage_id=[% stage.stage_id %]"> + <i class="fa fa-pencil"></i> Edit + </a> + <a class="btn btn-default btn-xs" href="?op=confirm_delete_stage&stage_id=[% stage.stage_id %]"> + <i class="fa fa-trash"></i> Delete + </a> + </span> + </li> + [% END %] + </ul> + </div> + [% END %] + + <p><a href="stockrotation.pl">Return to rotas</a></p> + + [% ELSIF (op == 'create_edit_stage') %] + + [% IF stage.id %] + <h3>Edit "[% Branches.GetName(stage.branchcode_id) %]"</h3> + [% ELSE %] + <h3>Create new stage</h3> + [% END %] + + [% IF error == 'invalid_form' %] + <div class="dialog alert"> + <h3>There was a problem with your form submission</h3> + </div> + [% END %] + + <form id="stage_form" method="post" enctype="multipart/form-data" class="validated"> + <fieldset class="rows"> + <ol> + <li> + <label class="required" for="branch">Branch:</label> + <select name="branchcode" id="branch"> + [% FOREACH branch IN branches %] + [% IF branch.branchcode == stage.branchcode_id %] + <option value="[% branch.branchcode %]" selected>[% Branches.GetName(branch.branchcode) %]</option> + [% ELSE %] + <option value="[% branch.branchcode %]">[% Branches.GetName(branch.branchcode) %]</option> + [% END %] + [% END %] + </select> + <span class="required">Required</span> + </li> + <li> + <label class="required" for="duration">Duration:</label> + <input type="text" id="duration" name="duration" value="[% stage.duration %]" required="required" placeholder="Duration (days)"> + <span class="required">Required</span> + </li> + </ol> + </fieldset> + <fieldset class="action"> + <input type="submit" value="Save"> + <a href="/cgi-bin/koha/tools/stockrotation.pl?op=manage_stages&rota_id=[% rota_id %]" class="cancel">Cancel</a> + </fieldset> + <input type="hidden" name="stage_id" value="[% stage.id %]"> + <input type="hidden" name="rota_id" value="[% rota_id %]"> + <input type="hidden" name="op" value="process_stage"> + </form> + [% ELSIF (op == 'confirm_remove_from_rota') %] + + <div class="dialog alert"> + <h3>Are you sure you wish to remove this item from it's rota</h3> + <p> + <a class="btn btn-default btn-xs approve" href="?op=remove_item_from_stage&item_id=[% item_id %]&stage_id=[% stage_id %]&rota_id=[% rota_id %]"><i class="fa fa-fw fa-check"></i>Yes</a> + <a class="btn btn-default btn-xs deny" href="?op=manage_items&rota_id=[% rota_id %]"><i class="fa fa-fw fa-remove"></i>No</a> + </p> + </div> + [% ELSIF (op == 'confirm_delete_stage') %] + + <div class="dialog alert"> + <h3>Are you sure you want to delete this stage?</h3> + [% IF stage.stockrotationitems.count > 0 %] + <p>This stage contains the following item(s):</p> + <ul> + [% FOREACH item IN stage.stockrotationitems %] + <li>[% item.itemnumber.biblio.title %] (Barcode: [% item.itemnumber.barcode %])</li> + [% END %] + </ul> + [% END %] + <p> + <a class="btn btn-default btn-xs approve" href="?op=delete_stage&stage_id=[% stage.stage_id %]"><i class="fa fa-fw fa-check"></i>Yes</a> + <a class="btn btn-default btn-xs deny" href="?op=manage_stages&rota_id=[% stage.rota.rota_id %]"><i class="fa fa-fw fa-remove"></i>No</a> + </p> + </div> + [% ELSIF (op == 'manage_items') %] + + [% INCLUDE 'stockrotation-toolbar.inc' %] + + [% IF error %] + <div class="dialog alert"> + [% IF error == "item_not_found" %] + <h3>The item was not found</h3> + [% ELSIF error == "already_on_rota" %] + <h3>This item is already on this rota</h3> + [% END %] + </div> + [% END %] + + <div> + <form id="add_rota_item_form" method="post" enctype="multipart/form-data" class="validated"> + <fieldset class="rows"> + <legend>Add item to "[% rota.title %]"</legend> + <ol> + <li> + <label for="barcode">Barcode:</label> + <input type="text" id="barcode" name="barcode" placeholder="Item barcode" autofocus> + </li> + </ol> + </fieldset> + <fieldset class="rows"> + <legend>Use a barcode file</legend> + <ol> + <li> + <label for="barcodefile">Barcode file:</label> + <input type="file" id="barcodefile" name="barcodefile"> + </li> + </ol> + </fieldset> + <fieldset class="action"> + <input type="submit" value="Save"> + </fieldset> + <input type="hidden" name="rota_id" value="[% rota.id %]"> + <input type="hidden" name="op" value="add_items_to_rota"> + </form> + </div> + + [% IF items.count > 0 %] + <h3>Manage items assigned to "[% rota.title %]"</h3> + <table id="sr_manage_items" class="items_table" role="grid"> + <thead> + <th>Barcode</th> + <th>Title</th> + <th>Author</th> + <th>Shelfmark</th> + <th class="NoSearch">In transit</th> + <th class="NoSort">Stages & duration in days<br>(current stage highlighted)</th> + <th class="NoSort"> </th> + </thead> + <tbody> + [% FOREACH item IN items %] + <tr> + <td><a href="/cgi-bin/koha/catalogue/moredetail.pl?itemnumber=[% item.id %]&biblionumber=[% item.itemnumber.biblio.id %]#item[% item.id %]">[% item.itemnumber.barcode %]</a></td> + <td><a href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=[% item.itemnumber.biblio.id %]">[% item.itemnumber.biblio.title %]</a></td> + <td>[% item.itemnumber.biblio.author %]</td> + <td>[% item.itemnumber.itemcallnumber %]</td> + <td>[% item.itemnumber.get_transfer ? 'Yes' : 'No' %]</td> + <td> + [% FOREACH this_stage IN stages %] + [% IF this_stage.stage_id == item.stage.stage_id %] + <span class="stage highlight_stage"> + [% ELSE %] + <span class="stage"> + [% END %] + [% Branches.GetName(this_stage.branchcode_id) %] ([% this_stage.duration %]) + </span> + » + [% END %] + [% IF stages.size > 0 %] + <span class="stage">[% rota.cyclical ? 'START' : 'END' %]</span> + [% END %] + </td> + <td class="actions"> + [% in_transit = item.itemnumber.get_transfer %] + [% IF !in_transit && stages.size > 1 %] + <a class="btn btn-default btn-xs" href="?op=move_to_next_stage&rota_id=[% rota.id %]&item_id=[% item.id %]&stage_id=[% item.stage.stage_id %]"> + [% ELSE %] + <a class="btn btn-default btn-xs" disabled> + [% END %] + <i class="fa fa-arrow-right"></i> + Move to next stage + </a> + [% IF !in_transit %] + <a class="btn btn-default btn-xs" href="?op=toggle_in_demand&stage_id=[% item.stage.stage_id %]&item_id=[% item.id %]&rota_id=[% rota.id %]"> + [% ELSE %] + <a class="btn btn-default btn-xs" disabled> + [% END %] + <i class="fa fa-fire"></i> + [% item.indemand ? 'Remove "In demand"' : 'Add "In demand"' %] + </a> + [% IF !in_transit %] + <a class="btn btn-default btn-xs" href="?op=confirm_remove_from_rota&stage_id=[% item.stage.stage_id %]&item_id=[% item.id %]&rota_id=[% rota.id %]"> + [% ELSE %] + <a class="btn btn-default btn-xs" disabled> + [% END %] + <i class="fa fa-trash"></i> + Remove from rota + </a> + </td> + </tr> + [% END %] + </tbody> + </table> + [% END %] + + <p><a href="stockrotation.pl">Return to rotas</a></p> + + [% ELSIF op == 'add_items_to_rota' %] + + + <div class="dialog message"> + <h3>Add items to rota report</h3> + </div> + <div> + [% IF barcode_status.ok.size > 0 %] + <h4>Items added to rota:</h4> + <ul> + [% FOREACH item_ok IN barcode_status.ok %] + <li>[% item_ok.biblio.title %]</li> + [% END %] + </ul> + [% END %] + [% IF barcode_status.on_this.size > 0 %] + <h4>Items already on this rota:</h4> + <ul> + [% FOREACH item_on_this IN barcode_status.on_this %] + <li>[% item_on_this.biblio.title %]</li> + [% END %] + </ul> + [% END %] + [% IF barcode_status.not_found.size > 0 %] + <h4>Barcodes not found:</h4> + <ul> + [% FOREACH barcode_not_found IN barcode_status.not_found %] + <li>[% barcode_not_found %]</li> + [% END %] + </ul> + [% END %] + [% IF barcode_status.on_other.size > 0 %] + <h4>Items found on other rotas:</h4> + <ul> + [% FOREACH item_on_other IN barcode_status.on_other %] + <li>[% item_on_other.biblio.title %]</li> + [% END %] + </ul> + [% END %] + </div> + [% IF barcode_status.on_other.size > 0 %] + <form id="add_rota_item_form" method="post" enctype="multipart/form-data"> + <fieldset> + <legend>Select items to move to this rota:</legend> + [% FOREACH item_on_other IN barcode_status.on_other %] + <li><input type="checkbox" name="move_item" value="[% item_on_other.itemnumber %]"> [% item_on_other.biblio.title %] (Currently on "[% item_on_other.stockrotationitem.stage.rota.title %]")</li> + [% END %] + + </fieldset> + <fieldset class="action"> + <input type="submit" value="Save"> + </fieldset> + <input type="hidden" name="rota_id" value="[% rota_id %]"> + <input type="hidden" name="op" value="move_items_to_rota"> + </form> + [% END %] + <p><a href="?op=manage_items&rota_id=[% rota_id %]">Return to rota</a></p> + + [% END %] + </div> + </div> + <div class="yui-b"> + [% INCLUDE 'tools-menu.inc' %] + </div> + </div> +</div> +[% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/tools-home.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/tools-home.tt index 4c8cca9192..0ff70a27dd 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/tools-home.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/tools-home.tt @@ -1,3 +1,5 @@ +[% USE Koha %] + [% INCLUDE 'doc-head-open.inc' %] <title>Koha › Tools</title> [% INCLUDE 'doc-head-close.inc' %] @@ -68,6 +70,11 @@ [% IF ( CAN_user_tools_batch_upload_patron_images ) %] <dt><a href="/cgi-bin/koha/tools/picture-upload.pl">Upload patron images</a></dt> <dd>Upload patron images in a batch or one at a time</dd> + [% END %] + + [% IF ( CAN_user_stockrotation_can_edit_rotas && Koha.Preference('StockRotation') ) %] + <dt><a href="/cgi-bin/koha/tools/stockrotation.pl">Stock rotation</a></dt> + <dd>Manage Stock rotation rotas, rota stages and rota items</dd> [% END %] </dl> </div> diff --git a/koha-tmpl/intranet-tmpl/prog/js/pages/stockrotation.js b/koha-tmpl/intranet-tmpl/prog/js/pages/stockrotation.js new file mode 100644 index 0000000000..276394518b --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/pages/stockrotation.js @@ -0,0 +1,65 @@ +function init() { + $('#ajax_status').hide(); + $('#ajax_saving_msg').hide(); + $('#ajax_saving_icon').hide(); + $('#ajax_success_icon').hide(); + $('#ajax_failed_icon').hide(); + $('#ajax_failed_msg').hide(); +} + +$(document).ready(function() { + var apiEndpoint = '/api/v1/rotas/'; + init(); + $('#sortable_stages').sortable({ + handle: '.drag_handle', + placeholder: 'drag_placeholder', + update: function(event, ui) { + init(); + $('#sortable_stages').sortable('disable'); + var rotaId = document.getElementById('sortable_stages').dataset.rotaId; + $('#ajax_saving_msg').text( + document.getElementById('ajax_status').dataset.savingMsg + ); + $('#ajax_saving_icon').show(); + $('#ajax_saving_msg').show(); + $('#ajax_status').fadeIn(); + var stageId = ui.item[0].id.replace(/^stage_/, ''); + var newIndex = ui.item.index(); + var newPosition = newIndex + 1; + $.ajax({ + method: 'PUT', + url: apiEndpoint + rotaId + '/stages/' + stageId + '/position', + processData: false, + contentType: 'application/json', + data: newPosition + }) + .done(function(data) { + $('#ajax_success_msg').text( + document.getElementById('ajax_status').dataset.successMsg + ); + $('#ajax_saving_icon').hide(); + $('#ajax_success_icon').show(); + $('#ajax_success_msg').show(); + setTimeout( + function() { + $('#ajax_status').fadeOut(); + }, + 700 + ); + }) + .fail(function(jqXHR, status, error) { + $('#ajax_failed_msg').text( + document.getElementById('ajax_status').dataset.failedMsg + + error + ); + $('#ajax_saving_icon').hide(); + $('#ajax_failed_icon').show(); + $('#ajax_failed_msg').show(); + $('#sortable_stages').sortable('cancel'); + }) + .always(function() { + $('#sortable_stages').sortable('enable'); + }) + } + }); +}); diff --git a/misc/cronjobs/stockrotation.pl b/misc/cronjobs/stockrotation.pl new file mode 100755 index 0000000000..d8914e42d9 --- /dev/null +++ b/misc/cronjobs/stockrotation.pl @@ -0,0 +1,489 @@ +#!/usr/bin/perl + +# Copyright 2016 PTFS Europe +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +=head1 NAME + +stockrotation.pl + +=head1 SYNOPSIS + + --[a]dmin-email An address to which email reports should also be sent + --[b]ranchcode Select branch to report on for 'email' reports (default: all) + --e[x]ecute Actually perform stockrotation housekeeping + --[r]eport Select either 'full' or 'email' + --[S]end-all Send email reports even if the report body is empty + --[s]end-email Send reports by email + --[h]elp Display this help message + +Cron script implementing scheduled stockrotation functionality. + +By default this script merely reports on the current status of the +stockrotation subsystem. In order to actually place items in transit, the +script must be run with the `execute` argument. + +`report` allows you to select the type of report that will be emitted. It's +set to 'full' by default. If the `email` report is selected, you can use the +`branchcode` parameter to specify which branch's report you would like to see. +The default is 'all'. + +`admin-email` is an additional email address to which we will send all email +reports in addition to sending them to branch email addresses. + +`send-email` will cause the script to send reports by email, and `send-all` +will cause even reports with an empty body to be sent. + +=head1 DESCRIPTION + +This script is used to move items from one stockrotationstage to the next, +if they are elible for processing. + +it should be run from cron like: + + stockrotation.pl --report email --send-email --execute + +Prior to that you can run the script from the command line without the +--execute and --send-email parameters to see what reports the script would +generate in 'production' mode. This is immensely useful for testing, or for +getting to understand how the stockrotation module works: you can set up +different scenarios, and then "query" the system on what it would do. + +Normally you would want to run this script once per day, probably around +midnight-ish to move any stockrotationitems along their rotas and to generate +the email reports for branch libraries. + +Each library will receive a report with "items of interest" for them for +today's rota checks. Each item there will be an item that should, according +to Koha, be located on the shelves of that branch, and which should be picked +up and checked in. The item will either: +- have been placed in transit to their new stage library; +- have been placed in transit to be returned to their current stage library; +- have just been added to a rota and will already be at the correct library; + +In the last case the item will be checked in and no message will pop up. In +the other cases a message will pop up requesting the item be posted to their +new branch. + +=head2 What does the --execute flag do? + +To understand this, you will need to know a little bit about the design of +this script and the stockrotation modules. + +This script operates in 3 phases: first it walks the graph of rotas, stages +and items. For each active rota, it investigates the items in each stage and +determines whether action is required. It does not perform any actions, it +just "sieves" all items on active rotas into "actionable" and "non-actionable" +baskets. We can use these baskets to perform actions against the items, or to +generate reports. + +During the second phase this script then loops through the actionable baskets, +and performs the relevant action (initiate, repatriate, advance) on each item. + +Finally, during the third phase we revisit the original baskets and we compile +reports (for instance per branch email reports). + +When the script is run without the "--execute" flag, we perform phase 1, skip +phase 2 and move straight onto phase 3. + +With the "--execute" flag we also perform the database operations. + +So with or without the flag, the report will look the same (except for the "No +database updates have been performed."). + +=cut + +use Modern::Perl; +use Getopt::Long qw/HelpMessage :config gnu_getopt/; +use Mail::Sendmail; +use C4::Context; +use Koha::Email; +use Koha::StockRotationRotas; + +my $admin_email = ''; +my $branch = 0; +my $execute = 0; +my $report = 'full'; +my $send_all = 0; +my $send_email = 0; + +my $ok = GetOptions( + 'admin-email|a=s' => \$admin_email, + 'branchcode|b=s' => sub { + my ($opt_name, $opt_value) = @_; + my $branches = Koha::Libraries->search( + {}, { order_by => { -asc => 'branchname'} } + ); + my $brnch = $branches->find($opt_value); + if ( $brnch ) { + $branch = $brnch; + return $brnch; + } else { + printf("Option $opt_name should be one of (name -> code):\n"); + while ( my $candidate = $branches->next ) { + printf( + " %-40s -> %s\n", $candidate->branchname, + $candidate->branchcode + ); + } + exit 1 + } + }, + 'execute|x' => \$execute, + 'report|r=s' => sub { + my ($opt_name, $opt_value) = @_; + if ( $opt_value eq 'full' || $opt_value eq 'email' ) { + $report = $opt_value; + } else { + printf("Option $opt_name should be either 'email' or 'full'.\n"); + exit 1; + } + }, + 'send-all|S' => \$send_all, + 'send-email|s' => \$send_email, + 'help|h|?' => sub { HelpMessage } +); +exit 1 unless ( $ok ); + +$send_email++ if ( $send_all ); # if we send all, then we must want emails. + +=head2 Helpers + +=head3 execute + + undef = execute($report); + +Perform the database updates, within a transaction, that are reported as +needing to be performed by $REPORT. + +$REPORT should be the return value of an invocation of `investigate`. + +This procedure WILL mess with your database. + +=cut + +sub execute { + my ( $data ) = @_; + + # Begin transaction + my $schema = Koha::Database->new->schema; + $schema->storage->txn_begin; + + # Carry out db updates + foreach my $item (@{$data->{items}}) { + my $reason = $item->{reason}; + if ( $reason eq 'repatriation' ) { + $item->{object}->repatriate; + } elsif ( grep { $reason eq $_ } + qw/in-demand advancement initiation/ ) { + $item->{object}->advance; + } + } + + # End transaction + $schema->storage->txn_commit; +} + +=head3 report_full + + my $full_report = report_full($report); + +Return an arrayref containing a string containing a detailed report about the +current state of the stockrotation subsystem. + +$REPORT should be the return value of `investigate`. + +No data in the database is manipulated by this procedure. + +=cut + +sub report_full { + my ( $data ) = @_; + + my $header = ""; + my $body = ""; + # Summary + $header .= sprintf " +STOCKROTATION REPORT +-------------------- + Total number of rotas: %5u + Inactive rotas: %5u + Active rotas: %5u + Total number of items: %5u + Inactive items: %5u + Stationary items: %5u + Actionable items: %5u + Total items to be initiated: %5u + Total items to be repatriated: %5u + Total items to be advanced: %5u + Total items in demand: %5u\n\n", + $data->{sum_rotas}, $data->{rotas_inactive}, $data->{rotas_active}, + $data->{sum_items}, $data->{items_inactive}, $data->{stationary}, + $data->{actionable}, $data->{initiable}, $data->{repatriable}, + $data->{advanceable}, $data->{indemand}; + + if (@{$data->{rotas}}) { # Per Rota details + $body .= sprintf "ROTAS DETAIL\n------------\n\n"; + foreach my $rota (@{$data->{rotas}}) { + $body .= sprintf "Details for %s [%s]:\n", + $rota->{name}, $rota->{id}; + $body .= sprintf "\n Items:"; # Rota item details + if (@{$rota->{items}}) { + $body .= join("", map { _print_item($_) } @{$rota->{items}}); + } else { + $body .= sprintf + "\n No items to be processed for this rota.\n"; + } + $body .= sprintf "\n Log:"; # Rota log details + if (@{$rota->{log}}) { + $body .= join("", map { _print_item($_) } @{$rota->{log}}); + } else { + $body .= sprintf "\n No items in log for this rota.\n\n"; + } + } + } + return [ $header, { + body => $body, # The body of the report + status => 1, # We have a meaningful report + no_branch_email => 1, # We don't expect branch email in report + } ]; +} + +=head3 report_email + + my $email_report = report_email($report); + +Returns an arrayref containing a header string, with basic report information, +and any number of 'per_branch' strings, containing a detailed report about the +current state of the stockrotation subsystem, from the perspective of those +individual branches. + +$REPORT should be the return value of `investigate`, and $BRANCH should be +either 0 (to indicate 'all'), or a specific Koha::Library object. + +No data in the database is manipulated by this procedure. + +=cut + +sub report_email { + my ( $data, $branch ) = @_; + + my $out = []; + my $header = ""; + # Summary + my $branched = $data->{branched}; + my $flag = 0; + + $header .= sprintf " +BRANCH-BASED STOCKROTATION REPORT +---------------------------------\n"; + push @{$out}, $header; + + if ( $branch ) { # Branch limited report + push @{$out}, _report_per_branch( + $branched->{$branch->branchcode}, $branch->branchcode, + $branch->branchname + ); + } elsif ( $data->{actionable} ) { # Full email report + while ( my ($branchcode_id, $details) = each %{$branched} ) { + push @{$out}, _report_per_branch( + $details, $details->{code}, $details->{name} + ) if ( @{$details->{items}} ); + } + } else { + push @{$out}, { + body => sprintf " +No actionable items at any libraries.\n\n", # The body of the report + no_branch_email => 1, # We don't expect branch email in report + }; + } + return $out; +} + +=head3 _report_per_branch + + my $branch_string = _report_per_branch($branch_details, $branchcode, $branchname); + +return a string containing details about the stockrotation items and their +status for the branch identified by $BRANCHCODE. + +This helper procedure is only used from within `report_email`. + +No data in the database is manipulated by this procedure. + +=cut + +sub _report_per_branch { + my ( $per_branch, $branchcode, $branchname ) = @_; + + my $out = ""; + my $status = 0; + + $out .= sprintf "\nStockrotation report for '%s' [%s]:\n", + $branchname || "N/A", $branchcode || "N/A"; + if ( $per_branch && @{$per_branch->{items}} ) { + $out .= sprintf " + Email: %s + Phone: %s + Items:", + $per_branch->{email} || "N/A", $per_branch->{phone} || "N/A"; + $status++; + } else { + $out .= sprintf "No items to be processed for this branch.\n\n"; + } + $out .= join("", map { + _print_item($_) unless $_->{reason} eq 'in-demand' + } @{$per_branch->{items}}); + return { + body => $out, # The body of the report + email_address => $per_branch->{email}, # The branch email address + status => $status, # We may have empty report... + }; +} + +=head3 _print_item + + my $string = _print_item($item_section); + +Return a string containing an overview about $ITEM_SECTION. + +This helper procedure is only used from within `report_email` and +`report_full`. + +No data in the database is manipulated by this procedure. + +=cut + +sub _print_item { + my ( $item ) = @_; + return sprintf " + Title: %s + Author: %s + Callnumber: %s + Location: %s + Barcode: %s + On loan?: %s + Status: %s + Current Library: %s [%s]\n\n", + $item->{title} || "N/A", $item->{author} || "N/A", + $item->{callnumber} || "N/A", $item->{location} || "N/A", + $item->{barcode} || "N/A", $item->{onloan} ? 'Yes' : 'No', + $item->{reason} || "N/A", $item->{branch}->branchname, + $item->{branch}->branchcode; +} + +=head3 emit + + undef = emit($params); + +$PARAMS should be a hashref of the following format: + admin_email: the address to which a copy of all reports should be sent. + execute: the flag indicating whether we performed db updates + send_all: the flag indicating whether we should send even empty reports + send_email: the flag indicating whether we want to emit to stdout or email + report: the data structure returned from one of the report procedures + +No data in the database is manipulated by this procedure. + +The return value is unspecified: we simply emit a message as a side-effect or +die. + +=cut + +sub emit { + my ( $params ) = @_; + + # REPORT is an arrayref of at least 2 elements: + # - The header for the report, which will be repeated for each part + # - a "part" for each report we want to emit + # PARTS are hashrefs: + # - part->{body}: the body of the report + # - part->{email_address}: the email address to send the report to + my $report = $params->{report}; + my $header = shift @{$report}; + my $parts = $report; + + foreach my $part (@{$parts}) { + # The final message is the header + body of this part. + my $msg = $header . $part->{body}; + $msg .= "No database updates have been performed.\n\n" + unless ( $params->{execute} ); + + if ( $params->{send_email} ) { # Only email if emails requested + if ( $part->{status} || $params->{send_all} ) { + # We have a report to send, or we want to send even empty + # reports. + my $message = Koha::Email->new; + + # Collate 'to' addresses + my @to = (); + if ( $part->{email_address} ) { + push @to, $part->{email_address}; + } elsif ( !$part->{no_branch_email} ) { + $msg = "***We tried to send a branch report, but we have no email address for this branch.***\n\n" . $msg; + push @to, C4::Context->preference('KohaAdminEmailAddress') + if ( C4::Context->preference('KohaAdminEmailAddress') ); + } + push @to, $params->{admin_email} if $params->{admin_email}; + + # Create email data. + my %mail = $message->create_message_headers( + { + to => join("; ", @to), + subject => "Stockrotation Email Report", + message => Encode::encode("utf8", $msg), + contenttype => 'text/plain; charset="utf8"', + } + ); + + # Send or die. + die($Mail::Sendmail::error) + unless Mail::Sendmail::sendmail(%mail); + } + + } else { # Else emit to stdout. + printf $msg; + } + } +} + +#### Main Code + +# Compile Stockrotation Report data +my $rotas = Koha::StockRotationRotas->search; +my $data = $rotas->investigate; + +# Perform db updates if requested +execute($data) if ( $execute ); + +# Emit Reports +my $out_report = {}; +$out_report = report_email($data, $branch) if $report eq 'email'; +$out_report = report_full($data, $branch) if $report eq 'full'; +emit({ + admin_email => $admin_email, + execute => $execute, + report => $out_report, + send_all => $send_all, + send_email => $send_email, +}); + +=head1 AUTHOR + +Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com> + +=cut diff --git a/t/db_dependent/Items.t b/t/db_dependent/Items.t index ef098734c9..fcbc621ac9 100755 --- a/t/db_dependent/Items.t +++ b/t/db_dependent/Items.t @@ -833,6 +833,67 @@ subtest 'Test logging for ModItem' => sub { $schema->resultset('ActionLog')->search()->delete(); ModItem({ location => $location }, $bibnum, $itemnumber); is( $schema->resultset('ActionLog')->count(), 1, 'Undefined value defaults to true, triggers logging' ); +}; + +subtest 'Check stockrotationitem relationship' => sub { + plan tests => 1; + + $schema->storage->txn_begin(); + + my $builder = t::lib::TestBuilder->new; + my $item = $builder->build({ source => 'Item' }); + + $builder->build({ + source => 'Stockrotationitem', + value => { itemnumber_id => $item->{itemnumber} } + }); + + my $sritem = Koha::Items->find($item->{itemnumber})->stockrotationitem; + isa_ok( $sritem, 'Koha::StockRotationItem', "Relationship works and correctly creates Koha::Object." ); + + $schema->storage->txn_rollback; +}; + +subtest 'Check add_to_rota method' => sub { + plan tests => 2; + + $schema->storage->txn_begin(); + + my $builder = t::lib::TestBuilder->new; + my $item = $builder->build({ source => 'Item' }); + my $rota = $builder->build({ source => 'Stockrotationrota' }); + my $srrota = Koha::StockRotationRotas->find($rota->{rota_id}); + + $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + + my $sritem = Koha::Items->find($item->{itemnumber}); + $sritem->add_to_rota($rota->{rota_id}); + + is( + Koha::StockRotationItems->find($item->{itemnumber})->stage_id, + $srrota->stockrotationstages->next->stage_id, + "Adding to a rota a new sritem item being assigned to its first stage." + ); + + my $newrota = $builder->build({ source => 'Stockrotationrota' }); + + my $srnewrota = Koha::StockRotationRotas->find($newrota->{rota_id}); + + $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $newrota->{rota_id} }, + }); + + $sritem->add_to_rota($newrota->{rota_id}); + + is( + Koha::StockRotationItems->find($item->{itemnumber})->stage_id, + $srnewrota->stockrotationstages->next->stage_id, + "Moving an item results in that sritem being assigned to the new first stage." + ); $schema->storage->txn_rollback; }; diff --git a/t/db_dependent/Koha/Libraries.t b/t/db_dependent/Koha/Libraries.t index d24ba3d6fe..1d72f73442 100644 --- a/t/db_dependent/Koha/Libraries.t +++ b/t/db_dependent/Koha/Libraries.t @@ -19,7 +19,7 @@ use Modern::Perl; -use Test::More tests => 4; +use Test::More tests => 12; use Koha::Library; use Koha::Libraries; @@ -53,6 +53,32 @@ is( $retrieved_library_1->branchname, $new_library_1->branchname, 'Find a librar $retrieved_library_1->delete; is( Koha::Libraries->search->count, $nb_of_libraries + 1, 'Delete should have deleted the library' ); +$retrieved_category_2->delete; +is( Koha::LibraryCategories->search->count, $nb_of_categories + 2, 'Delete should have deleted the library category' ); + +# Stockrotation relationship testing + +my $new_library_sr = $builder->build({ source => 'Branch' }); + +$builder->build({ + source => 'Stockrotationstage', + value => { branchcode_id => $new_library_sr->{branchcode} }, +}); +$builder->build({ + source => 'Stockrotationstage', + value => { branchcode_id => $new_library_sr->{branchcode} }, +}); +$builder->build({ + source => 'Stockrotationstage', + value => { branchcode_id => $new_library_sr->{branchcode} }, +}); + +my $srstages = Koha::Libraries->find($new_library_sr->{branchcode}) + ->stockrotationstages; +is( $srstages->count, 3, 'Correctly fetched stockrotationstages associated with this branch'); + +isa_ok( $srstages->next, 'Koha::StockRotationStage', "Relationship correctly creates Koha::Objects." ); + $schema->storage->txn_rollback; subtest '->get_effective_marcorgcode' => sub { diff --git a/t/db_dependent/StockRotationItems.t b/t/db_dependent/StockRotationItems.t new file mode 100644 index 0000000000..2d9bf89a39 --- /dev/null +++ b/t/db_dependent/StockRotationItems.t @@ -0,0 +1,393 @@ +#!/usr/bin/perl + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use DateTime; +use DateTime::Duration; +use Koha::Database; +use Koha::Item::Transfer; +use t::lib::TestBuilder; + +use Test::More tests => 8; + +my $schema = Koha::Database->new->schema; + +use_ok('Koha::StockRotationItems'); +use_ok('Koha::StockRotationItem'); + +my $builder = t::lib::TestBuilder->new; + +subtest 'Basic object tests' => sub { + + plan tests => 5; + + $schema->storage->txn_begin; + + my $itm = $builder->build({ source => 'Item' }); + my $stage = $builder->build({ source => 'Stockrotationstage' }); + + my $item = $builder->build({ + source => 'Stockrotationitem', + value => { + itemnumber_id => $itm->{itemnumber}, + stage_id => $stage->{stage_id}, + }, + }); + + my $sritem = Koha::StockRotationItems->find($item->{itemnumber_id}); + isa_ok( + $sritem, + 'Koha::StockRotationItem', + "Correctly create and load a stock rotation item." + ); + + # Relationship to rota + isa_ok( $sritem->itemnumber, 'Koha::Item', "Fetched related item." ); + is( $sritem->itemnumber->itemnumber, $itm->{itemnumber}, "Related rota OK." ); + + # Relationship to stage + isa_ok( $sritem->stage, 'Koha::StockRotationStage', "Fetched related stage." ); + is( $sritem->stage->stage_id, $stage->{stage_id}, "Related stage OK." ); + + + $schema->storage->txn_rollback; +}; + +subtest 'Tests for needs_repatriating' => sub { + + plan tests => 4; + + $schema->storage->txn_begin; + + # Setup a pristine stockrotation context. + my $sritem = $builder->build({ source => 'Stockrotationitem' }); + my $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbitem->itemnumber->homebranch($dbitem->stage->branchcode_id); + $dbitem->itemnumber->holdingbranch($dbitem->stage->branchcode_id); + $dbitem->stage->position(1); + + my $dbrota = $dbitem->stage->rota; + my $newstage = $builder->build({ + source => 'Stockrotationstage', + value => { + rota_id => $dbrota->rota_id, + position => 2, + } + }); + + # - homebranch == holdingbranch [0] + is( + $dbitem->needs_repatriating, 0, + "Homebranch == Holdingbranch." + ); + + my $branch = $builder->build({ source => 'Branch' }); + $dbitem->itemnumber->holdingbranch($branch->{branchcode}); + + # - homebranch != holdingbranch [1] + is( + $dbitem->needs_repatriating, 1, + "Homebranch != holdingbranch." + ); + + # Set to incorrect homebranch. + $dbitem->itemnumber->holdingbranch($dbitem->stage->branchcode_id); + $dbitem->itemnumber->homebranch($branch->{branchcode}); + # - homebranch != stockrotationstage.branch & not in transit [1] + is( + $dbitem->needs_repatriating, 1, + "Homebranch != StockRotationStage.Branchcode_id & not in transit." + ); + + # Set to in transit (by implication). + $dbitem->stage($newstage->{stage_id}); + # - homebranch != stockrotaitonstage.branch & in transit [0] + is( + $dbitem->needs_repatriating, 1, + "homebranch != stockrotaitonstage.branch & in transit." + ); + + $schema->storage->txn_rollback; +}; + +subtest "Tests for repatriate." => sub { + plan tests => 3; + $schema->storage->txn_begin; + my $sritem = $builder->build({ source => 'Stockrotationitem' }); + my $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbitem->stage->position(1); + $dbitem->stage->duration(50); + my $branch = $builder->build({ source => 'Branch' }); + $dbitem->itemnumber->holdingbranch($branch->{branchcode}); + + # Test a straight up repatriate + ok($dbitem->repatriate, "Repatriation done."); + my $intransfer = $dbitem->itemnumber->get_transfer; + is($intransfer->frombranch, $branch->{branchcode}, "Origin correct."); + is($intransfer->tobranch, $dbitem->stage->branchcode_id, "Target Correct."); + + $schema->storage->txn_rollback; +}; + +subtest "Tests for needs_advancing." => sub { + plan tests => 6; + $schema->storage->txn_begin; + + # Test behaviour of item freshly added to rota. + my $sritem = $builder->build({ + source => 'Stockrotationitem', + value => { 'fresh' => 1, }, + }); + my $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + is($dbitem->needs_advancing, 1, "An item that is fresh will always need advancing."); + + # Setup a pristine stockrotation context. + $sritem = $builder->build({ + source => 'Stockrotationitem', + value => { 'fresh' => 0,} + }); + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbitem->itemnumber->homebranch($dbitem->stage->branchcode_id); + $dbitem->itemnumber->holdingbranch($dbitem->stage->branchcode_id); + $dbitem->stage->position(1); + $dbitem->stage->duration(50); + + my $dbtransfer = Koha::Item::Transfer->new({ + 'itemnumber' => $dbitem->itemnumber_id, + 'frombranch' => $dbitem->stage->branchcode_id, + 'tobranch' => $dbitem->stage->branchcode_id, + 'datesent' => DateTime->now, + 'datearrived' => undef, + 'comments' => "StockrotationAdvance", + })->store; + + # Test item will not be advanced if in transit. + is($dbitem->needs_advancing, 0, "Not ready to advance: in transfer."); + # Test item will not be advanced if in transit even if fresh. + $dbitem->fresh(1)->store; + is($dbitem->needs_advancing, 0, "Not ready to advance: in transfer (fresh)."); + $dbitem->fresh(0)->store; + + # Test item will not be advanced if it has not spent enough time. + $dbtransfer->datearrived(DateTime->now)->store; + is($dbitem->needs_advancing, 0, "Not ready to advance: Not spent enough time."); + # Test item will be advanced if it has not spent enough time, but is fresh. + $dbitem->fresh(1)->store; + is($dbitem->needs_advancing, 1, "Advance: Not spent enough time, but fresh."); + $dbitem->fresh(0)->store; + + # Test item will be advanced if it has spent enough time. + $dbtransfer->datesent( # Item was sent 100 days ago... + DateTime->now - DateTime::Duration->new( days => 100 ) + )->store; + $dbtransfer->datearrived( # And arrived 75 days ago. + DateTime->now - DateTime::Duration->new( days => 75 ) + )->store; + is($dbitem->needs_advancing, 1, "Ready to be advanced."); + + $schema->storage->txn_rollback; +}; + +subtest "Tests for advance." => sub { + plan tests => 15; + $schema->storage->txn_begin; + + my $sritem = $builder->build({ + source => 'Stockrotationitem', + value => { 'fresh' => 1 } + }); + my $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbitem->itemnumber->holdingbranch($dbitem->stage->branchcode_id); + my $dbstage = $dbitem->stage; + $dbstage->position(1)->duration(50)->store; # Configure stage. + # Configure item + $dbitem->itemnumber->holdingbranch($dbstage->branchcode_id)->store; + $dbitem->itemnumber->homebranch($dbstage->branchcode_id)->store; + # Sanity check + is($dbitem->stage->stage_id, $dbstage->stage_id, "Stage sanity check."); + + # Test if an item is fresh, always move to first stage. + is($dbitem->fresh, 1, "Fresh is correct."); + $dbitem->advance; + is($dbitem->stage->stage_id, $dbstage->stage_id, "Stage is first stage after fresh advance."); + is($dbitem->fresh, 0, "Fresh reset after advance."); + + # Test cases of single stage + $dbstage->rota->cyclical(1)->store; # Set Rota to cyclical. + ok($dbitem->advance, "Single stage cyclical advance done."); + ## Refetch dbitem + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + is($dbitem->stage->stage_id, $dbstage->stage_id, "Single stage cyclical stage OK."); + + # Test with indemand advance + $dbitem->indemand(1)->store; + ok($dbitem->advance, "Indemand item advance done."); + ## Refetch dbitem + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + is($dbitem->indemand, 0, "Indemand OK."); + is($dbitem->stage->stage_id, $dbstage->stage_id, "Indemand item advance stage OK."); + + # Multi stages + my $srstage = $builder->build({ + source => 'Stockrotationstage', + value => { duration => 50 } + }); + my $dbstage2 = Koha::StockRotationStages->find($srstage->{stage_id}); + $dbstage2->move_to_group($dbitem->stage->rota_id); + $dbstage2->move_last; + + # Test a straight up advance + ok($dbitem->advance, "Advancement done."); + ## Refetch dbitem + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + ## Test results + is($dbitem->stage->stage_id, $dbstage2->stage_id, "Stage updated."); + my $intransfer = $dbitem->itemnumber->get_transfer; + is($intransfer->frombranch, $dbstage->branchcode_id, "Origin correct."); + is($intransfer->tobranch, $dbstage2->branchcode_id, "Target Correct."); + + $dbstage->rota->cyclical(0)->store; # Set Rota to non-cyclical. + + # Arrive at new branch + $intransfer->datearrived(DateTime->now)->store; + $dbitem->itemnumber->holdingbranch($srstage->{branchcode_id})->store; + $dbitem->itemnumber->homebranch($srstage->{branchcode_id})->store; + + # Advance again, Remove from rota. + ok($dbitem->advance, "Non-cyclical advance."); + ## Refetch dbitem + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + is($dbitem, undef, "StockRotationItem has been removed."); + + $schema->storage->txn_rollback; +}; + +subtest "Tests for investigate (singular)." => sub { + plan tests => 7; + $schema->storage->txn_begin; + + # Test brand new item's investigation ['initiation'] + my $sritem = $builder->build({ source => 'Stockrotationitem', value => { fresh => 1 } }); + my $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + is($dbitem->investigate->{reason}, 'initiation', "fresh item initiates."); + + # Test brand new item at stagebranch ['initiation'] + $sritem = $builder->build({ source => 'Stockrotationitem', value => { fresh => 1 } }); + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbitem->itemnumber->homebranch($dbitem->stage->branchcode_id)->store; + $dbitem->itemnumber->holdingbranch($dbitem->stage->branchcode_id)->store; + is($dbitem->investigate->{reason}, 'initiation', "fresh item at stagebranch initiates."); + + # Test item not at stagebranch with branchtransfer history ['repatriation'] + $sritem = $builder->build({ + source => 'Stockrotationitem', + value => { 'fresh' => 0,} + }); + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + my $dbtransfer = Koha::Item::Transfer->new({ + 'itemnumber' => $dbitem->itemnumber_id, + 'frombranch' => $dbitem->itemnumber->homebranch, + 'tobranch' => $dbitem->itemnumber->homebranch, + 'datesent' => DateTime->now, + 'datearrived' => DateTime->now, + 'comments' => "StockrotationAdvance", + })->store; + is($dbitem->investigate->{reason}, 'repatriation', "older item repatriates."); + + # Test item at stagebranch with branchtransfer history ['not-ready'] + $sritem = $builder->build({ + source => 'Stockrotationitem', + value => { 'fresh' => 0,} + }); + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbtransfer = Koha::Item::Transfer->new({ + 'itemnumber' => $dbitem->itemnumber_id, + 'frombranch' => $dbitem->itemnumber->homebranch, + 'tobranch' => $dbitem->stage->branchcode_id, + 'datesent' => DateTime->now, + 'datearrived' => DateTime->now, + 'comments' => "StockrotationAdvance", + })->store; + $dbitem->itemnumber->homebranch($dbitem->stage->branchcode_id)->store; + $dbitem->itemnumber->holdingbranch($dbitem->stage->branchcode_id)->store; + is($dbitem->investigate->{reason}, 'not-ready', "older item at stagebranch not-ready."); + + # Test item due for advancement ['advancement'] + $sritem = $builder->build({ source => 'Stockrotationitem', value => { fresh => 0 } }); + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbitem->indemand(0)->store; + $dbitem->stage->duration(50)->store; + my $sent_duration = DateTime::Duration->new( days => 55); + my $arrived_duration = DateTime::Duration->new( days => 52); + $dbtransfer = Koha::Item::Transfer->new({ + 'itemnumber' => $dbitem->itemnumber_id, + 'frombranch' => $dbitem->itemnumber->homebranch, + 'tobranch' => $dbitem->stage->branchcode_id, + 'datesent' => DateTime->now - $sent_duration, + 'datearrived' => DateTime->now - $arrived_duration, + 'comments' => "StockrotationAdvance", + })->store; + $dbitem->itemnumber->homebranch($dbitem->stage->branchcode_id)->store; + $dbitem->itemnumber->holdingbranch($dbitem->stage->branchcode_id)->store; + is($dbitem->investigate->{reason}, 'advancement', + "Item ready for advancement."); + + # Test item due for advancement but in-demand ['in-demand'] + $sritem = $builder->build({ source => 'Stockrotationitem', value => { fresh => 0 } }); + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbitem->indemand(1)->store; + $dbitem->stage->duration(50)->store; + $sent_duration = DateTime::Duration->new( days => 55); + $arrived_duration = DateTime::Duration->new( days => 52); + $dbtransfer = Koha::Item::Transfer->new({ + 'itemnumber' => $dbitem->itemnumber_id, + 'frombranch' => $dbitem->itemnumber->homebranch, + 'tobranch' => $dbitem->stage->branchcode_id, + 'datesent' => DateTime->now - $sent_duration, + 'datearrived' => DateTime->now - $arrived_duration, + 'comments' => "StockrotationAdvance", + })->store; + $dbitem->itemnumber->homebranch($dbitem->stage->branchcode_id)->store; + $dbitem->itemnumber->holdingbranch($dbitem->stage->branchcode_id)->store; + is($dbitem->investigate->{reason}, 'in-demand', + "Item advances, but in-demand."); + + # Test item ready for advancement, but at wrong library ['repatriation'] + $sritem = $builder->build({ source => 'Stockrotationitem', value => { fresh => 0 } }); + $dbitem = Koha::StockRotationItems->find($sritem->{itemnumber_id}); + $dbitem->indemand(0)->store; + $dbitem->stage->duration(50)->store; + $sent_duration = DateTime::Duration->new( days => 55); + $arrived_duration = DateTime::Duration->new( days => 52); + $dbtransfer = Koha::Item::Transfer->new({ + 'itemnumber' => $dbitem->itemnumber_id, + 'frombranch' => $dbitem->itemnumber->homebranch, + 'tobranch' => $dbitem->stage->branchcode_id, + 'datesent' => DateTime->now - $sent_duration, + 'datearrived' => DateTime->now - $arrived_duration, + 'comments' => "StockrotationAdvance", + })->store; + is($dbitem->investigate->{reason}, 'repatriation', + "Item advances, but not at stage branch."); + + $schema->storage->txn_rollback; +}; + +1; diff --git a/t/db_dependent/StockRotationRotas.t b/t/db_dependent/StockRotationRotas.t new file mode 100644 index 0000000000..f1b2aa6d78 --- /dev/null +++ b/t/db_dependent/StockRotationRotas.t @@ -0,0 +1,175 @@ +#!/usr/bin/perl + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Koha::Database; +use t::lib::TestBuilder; + +use Test::More tests => 5; + +my $schema = Koha::Database->new->schema; + +use_ok('Koha::StockRotationRotas'); +use_ok('Koha::StockRotationRota'); + +subtest 'Basic object tests' => sub { + + plan tests => 5; + + $schema->storage->txn_begin; + + my $builder = t::lib::TestBuilder->new; + + my $rota = $builder->build({ source => 'Stockrotationrota' }); + + my $srrota = Koha::StockRotationRotas->find($rota->{rota_id}); + isa_ok( + $srrota, + 'Koha::StockRotationRota', + "Correctly create and load a stock rotation rota." + ); + + $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + + my $srstages = $srrota->stockrotationstages; + is( $srstages->count, 3, 'Correctly fetched stockrotationstages associated with this rota'); + + isa_ok( $srstages->next, 'Koha::StockRotationStage', "Relationship correctly creates Koha::Objects." ); + + #### Test add_item + + my $item = $builder->build({ source => 'Item' }); + + $srrota->add_item($item->{itemnumber}); + + is( + Koha::StockRotationItems->find($item->{itemnumber})->stage_id, + $srrota->first_stage->stage_id, + "Adding an item results in a new sritem item being assigned to the first stage." + ); + + my $newrota = $builder->build({ source => 'Stockrotationrota' }); + + my $srnewrota = Koha::StockRotationRotas->find($newrota->{rota_id}); + + $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $newrota->{rota_id} }, + }); + + $srnewrota->add_item($item->{itemnumber}); + + is( + Koha::StockRotationItems->find($item->{itemnumber})->stage_id, + $srnewrota->stockrotationstages->next->stage_id, + "Moving an item results in that sritem being assigned to the new first stage." + ); + + $schema->storage->txn_rollback; +}; + +subtest '->first_stage test' => sub { + plan tests => 2; + + $schema->storage->txn_begin; + + my $builder = t::lib::TestBuilder->new; + + my $rota = $builder->build({ source => 'Stockrotationrota' }); + + my $stage1 = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + my $stage2 = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + my $stage3 = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + + my $srrota = Koha::StockRotationRotas->find($rota->{rota_id}); + my $srstage2 = Koha::StockRotationStages->find($stage2->{stage_id}); + my $firststage = $srstage2->first_sibling || $srstage2; + + is( $srrota->first_stage->stage_id, $firststage->stage_id, "First stage works" ); + + $srstage2->move_first; + + is( Koha::StockRotationRotas->find($rota->{rota_id})->first_stage->stage_id, $stage2->{stage_id}, "Stage re-organized" ); + + $schema->storage->txn_rollback; +}; + +subtest '->items test' => sub { + plan tests => 1; + + $schema->storage->txn_begin; + + my $builder = t::lib::TestBuilder->new; + + my $rota = $builder->build({ source => 'Stockrotationrota' }); + + my $stage1 = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + my $stage2 = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + my $stage3 = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id} }, + }); + + map { $builder->build({ + source => 'Stockrotationitem', + value => { stage_id => $_ }, + }) } ( + $stage1->{stage_id}, $stage1->{stage_id}, + $stage2->{stage_id}, $stage2->{stage_id}, + $stage3->{stage_id}, $stage3->{stage_id}, + ); + + my $srrota = Koha::StockRotationRotas->find($rota->{rota_id}); + + is( + $srrota->stockrotationitems->count, + 6, "Correct number of items" + ); + + $schema->storage->txn_rollback; +}; + +1; diff --git a/t/db_dependent/StockRotationStages.t b/t/db_dependent/StockRotationStages.t new file mode 100644 index 0000000000..e6ea69315e --- /dev/null +++ b/t/db_dependent/StockRotationStages.t @@ -0,0 +1,377 @@ +#!/usr/bin/perl + +# Copyright PTFS Europe 2016 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Koha::Database; +use t::lib::TestBuilder; + +use Test::More tests => 6; + +my $schema = Koha::Database->new->schema; + +use_ok('Koha::StockRotationStages'); +use_ok('Koha::StockRotationStage'); + +my $builder = t::lib::TestBuilder->new; + +subtest 'Basic object tests' => sub { + plan tests => 5; + + $schema->storage->txn_begin; + + my $library = $builder->build({ source => 'Branch' }); + my $rota = $builder->build({ source => 'Stockrotationrota' }); + my $stage = $builder->build({ + source => 'Stockrotationstage', + value => { + branchcode_id => $library->{branchcode}, + rota_id => $rota->{rota_id}, + }, + }); + + my $srstage = Koha::StockRotationStages->find($stage->{stage_id}); + isa_ok( + $srstage, + 'Koha::StockRotationStage', + "Correctly create and load a stock rotation stage." + ); + + # Relationship to library + isa_ok( $srstage->branchcode, 'Koha::Library', "Fetched related branch." ); + is( $srstage->branchcode->branchcode, $library->{branchcode}, "Related branch OK." ); + + # Relationship to rota + isa_ok( $srstage->rota, 'Koha::StockRotationRota', "Fetched related rota." ); + is( $srstage->rota->rota_id, $rota->{rota_id}, "Related rota OK." ); + + $schema->storage->txn_rollback; +}; + +subtest 'DBIx::Class::Ordered tests' => sub { + plan tests => 33; + + $schema->storage->txn_begin; + + my $library = $builder->build({ source => 'Branch' }); + my $rota = $builder->build({ source => 'Stockrotationrota' }); + my $stagefirst = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id}, position => 1 } + }); + my $stageprevious = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id}, position => 2 } + }); + my $stage = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id}, position => 3 }, + }); + my $stagenext = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id}, position => 4 } + }); + my $stagelast = $builder->build({ + source => 'Stockrotationstage', + value => { rota_id => $rota->{rota_id}, position => 5 } + }); + + my $srstage = Koha::StockRotationStages->find($stage->{stage_id}); + + is($srstage->siblings->count, 4, "Siblings works."); + is($srstage->previous_siblings->count, 2, "Previous Siblings works."); + is($srstage->next_siblings->count, 2, "Next Siblings works."); + + my $map = { + first_sibling => $stagefirst, + previous_sibling => $stageprevious, + next_sibling => $stagenext, + last_sibling => $stagelast, + }; + # Test plain relations: + while ( my ( $srxsr, $check ) = each %{$map} ) { + my $sr = $srstage->$srxsr; + isa_ok($sr, 'Koha::StockRotationStage', "Fetched using '$srxsr'."); + is($sr->stage_id, $check->{stage_id}, "'$srxsr' data is correct."); + }; + + # Test mutators + ## Move Previous + ok($srstage->move_previous, "Previous."); + is($srstage->previous_sibling->stage_id, $stagefirst->{stage_id}, "Previous, correct previous."); + is($srstage->next_sibling->stage_id, $stageprevious->{stage_id}, "Previous, correct next."); + ## Move Next + ok($srstage->move_next, "Back to middle."); + is($srstage->previous_sibling->stage_id, $stageprevious->{stage_id}, "Middle, correct previous."); + is($srstage->next_sibling->stage_id, $stagenext->{stage_id}, "Middle, correct next."); + ## Move First + ok($srstage->move_first, "First."); + is($srstage->previous_sibling, 0, "First, correct previous."); + is($srstage->next_sibling->stage_id, $stagefirst->{stage_id}, "First, correct next."); + ## Move Last + ok($srstage->move_last, "Last."); + is($srstage->previous_sibling->stage_id, $stagelast->{stage_id}, "Last, correct previous."); + is($srstage->next_sibling, 0, "Last, correct next."); + ## Move To + + ### Out of range moves. + is( + $srstage->move_to($srstage->siblings->count + 2), + 0, "Move above count of stages." + ); + is($srstage->move_to(0), 0, "Move to 0th position."); + is($srstage->move_to(-1), 0, "Move to negative position."); + + ### Move To + ok($srstage->move_to(3), "Move."); + is($srstage->previous_sibling->stage_id, $stageprevious->{stage_id}, "Move, correct previous."); + is($srstage->next_sibling->stage_id, $stagenext->{stage_id}, "Move, correct next."); + + # Group manipulation + my $newrota = $builder->build({ source => 'Stockrotationrota' }); + ok($srstage->move_to_group($newrota->{rota_id}), "Move to Group."); + is(Koha::StockRotationStages->find($srstage->stage_id)->rota_id, $newrota->{rota_id}, "Moved correctly."); + + # Delete in ordered context + ok($srstage->delete, "Deleted OK."); + is( + Koha::StockRotationStages->find($stageprevious)->next_sibling->stage_id, + $stagenext->{stage_id}, + "Delete, correctly re-ordered." + ); + + $schema->storage->txn_rollback; +}; + +subtest 'Relationship to stockrotationitems' => sub { + plan tests => 2; + + $schema->storage->txn_begin; + my $stage = $builder->build({ source => 'Stockrotationstage' }); + + $builder->build({ + source => 'Stockrotationitem', + value => { stage_id => $stage->{stage_id} }, + }); + $builder->build({ + source => 'Stockrotationitem', + value => { stage_id => $stage->{stage_id} }, + }); + $builder->build({ + source => 'Stockrotationitem', + value => { stage_id => $stage->{stage_id} }, + }); + + my $srstage = Koha::StockRotationStages->find($stage->{stage_id}); + my $sritems = $srstage->stockrotationitems; + is( + $sritems->count, 3, + 'Correctly fetched stockrotationitems associated with this stage' + ); + + isa_ok( + $sritems->next, 'Koha::StockRotationItem', + "Relationship correctly creates Koha::Objects." + ); + + $schema->storage->txn_rollback; +}; + + +subtest 'Tests for investigate (singular)' => sub { + + plan tests => 3; + + # In this subtest series we will primarily be testing whether items end up + # in the correct 'branched' section of the stage-report. We don't care + # for item reasons here, as they are tested in StockRotationItems. + + # We will run tests on first on an empty report (the base-case) and then + # on a populated report. + + # We will need: + # - Libraries which will hold the Items + # - Rota Which containing the related stages + # + Stages on which we run investigate + # * Items on the stages + + $schema->storage->txn_begin; + + # Libraries + my $library1 = $builder->build({ source => 'Branch' }); + my $library2 = $builder->build({ source => 'Branch' }); + my $library3 = $builder->build({ source => 'Branch' }); + + my $stage1lib = $builder->build({ source => 'Branch' }); + my $stage2lib = $builder->build({ source => 'Branch' }); + my $stage3lib = $builder->build({ source => 'Branch' }); + my $stage4lib = $builder->build({ source => 'Branch' }); + + my $libraries = [ $library1, $library2, $library3, $stage1lib, $stage2lib, + $stage3lib, $stage4lib ]; + + # Rota + my $rota = $builder->build({ + source => 'Stockrotationrota', + value => { cyclical => 0 }, + }); + + # Stages + my $stage1 = $builder->build({ + source => 'Stockrotationstage', + value => { + rota_id => $rota->{rota_id}, + branchcode_id => $stage1lib->{branchcode}, + duration => 10, + position => 1, + }, + }); + my $stage2 = $builder->build({ + source => 'Stockrotationstage', + value => { + rota_id => $rota->{rota_id}, + branchcode_id => $stage2lib->{branchcode}, + duration => 20, + position => 2, + }, + }); + my $stage3 = $builder->build({ + source => 'Stockrotationstage', + value => { + rota_id => $rota->{rota_id}, + branchcode_id => $stage3lib->{branchcode}, + duration => 10, + position => 3, + }, + }); + my $stage4 = $builder->build({ + source => 'Stockrotationstage', + value => { + rota_id => $rota->{rota_id}, + branchcode_id => $stage4lib->{branchcode}, + duration => 20, + position => 4, + }, + }); + + # Test on an empty report. + my $spec = { + $library1->{branchcode} => 1, + $library2->{branchcode} => 1, + $library3->{branchcode} => 1, + $stage1lib->{branchcode} => 2, + $stage2lib->{branchcode} => 1, + $stage3lib->{branchcode} => 3, + $stage4lib->{branchcode} => 4 + }; + while ( my ( $code, $count ) = each %{$spec} ) { + my $cnt = 0; + while ( $cnt < $count ) { + my $item = $builder->build({ + source => 'Stockrotationitem', + value => { + stage_id => $stage1->{stage_id}, + indemand => 0, + fresh => 1, + } + }); + my $dbitem = Koha::StockRotationItems->find($item); + $dbitem->itemnumber->homebranch($code) + ->holdingbranch($code)->store; + $cnt++; + } + } + my $report = Koha::StockRotationStages + ->find($stage1->{stage_id})->investigate; + my $results = []; + foreach my $lib ( @{$libraries} ) { + my $items = $report->{branched}->{$lib->{branchcode}}->{items} || []; + push @{$results}, + scalar @{$items}; + } + + # Items assigned to stag1lib -> log, hence $results[4] = 0; + is_deeply( $results, [ 1, 1, 1, 2, 1, 3, 4 ], "Empty report test 1."); + + # Now we test by adding the next stage's items to the same report. + $spec = { + $library1->{branchcode} => 3, + $library2->{branchcode} => 2, + $library3->{branchcode} => 1, + $stage1lib->{branchcode} => 4, + $stage2lib->{branchcode} => 2, + $stage3lib->{branchcode} => 0, + $stage4lib->{branchcode} => 3 + }; + while ( my ( $code, $count ) = each %{$spec} ) { + my $cnt = 0; + while ( $cnt < $count ) { + my $item = $builder->build({ + source => 'Stockrotationitem', + value => { + stage_id => $stage2->{stage_id}, + indemand => 0, + fresh => 1, + } + }); + my $dbitem = Koha::StockRotationItems->find($item); + $dbitem->itemnumber->homebranch($code) + ->holdingbranch($code)->store; + $cnt++; + } + } + + $report = Koha::StockRotationStages + ->find($stage2->{stage_id})->investigate($report); + $results = []; + foreach my $lib ( @{$libraries} ) { + my $items = $report->{branched}->{$lib->{branchcode}}->{items} || []; + push @{$results}, + scalar @{$items}; + } + is_deeply( $results, [ 4, 3, 2, 6, 3, 3, 7 ], "full report test."); + + # Carry out db updates + foreach my $item (@{$report->{items}}) { + my $reason = $item->{reason}; + if ( $reason eq 'repatriation' ) { + $item->{object}->repatriate; + } elsif ( grep { $reason eq $_ } + qw/in-demand advancement initiation/ ) { + $item->{object}->advance; + } + } + + $report = Koha::StockRotationStages + ->find($stage1->{stage_id})->investigate; + $results = []; + foreach my $lib ( @{$libraries} ) { + my $items = $report->{branched}->{$lib->{branchcode}}->{items} || []; + push @{$results}, + scalar @{$items}; + } + # All items have been 'initiated', which means they are either happily in + # transit or happily at the library they are supposed to be. Either way + # they will register as 'not-ready' in the stock rotation report. + is_deeply( $results, [ 0, 0, 0, 0, 0, 0, 0 ], "All items now in logs."); + + $schema->storage->txn_rollback; +}; + +1; diff --git a/t/db_dependent/api/v1/stockrotationstage.t b/t/db_dependent/api/v1/stockrotationstage.t new file mode 100644 index 0000000000..c76e9b1891 --- /dev/null +++ b/t/db_dependent/api/v1/stockrotationstage.t @@ -0,0 +1,172 @@ +#!/usr/bin/env perl + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Test::More tests => 1; +use Test::Mojo; +use Test::Warn; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use C4::Auth; +use Koha::StockRotationStages; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +# FIXME: sessionStorage defaults to mysql, but it seems to break transaction handling +# this affects the other REST api tests +t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); + +my $remote_address = '127.0.0.1'; +my $t = Test::Mojo->new('Koha::REST::V1'); + +subtest 'move() tests' => sub { + + plan tests => 16; + + $schema->storage->txn_begin; + + my ( $unauthorized_borrowernumber, $unauthorized_session_id ) = + create_user_and_session( { authorized => 0 } ); + my ( $authorized_borrowernumber, $authorized_session_id ) = + create_user_and_session( { authorized => 1 } ); + + my $library1 = $builder->build({ source => 'Branch' }); + my $library2 = $builder->build({ source => 'Branch' }); + my $rota = $builder->build({ source => 'Stockrotationrota' }); + my $stage1 = $builder->build({ + source => 'Stockrotationstage', + value => { + branchcode_id => $library1->{branchcode}, + rota_id => $rota->{rota_id}, + } + }); + my $stage2 = $builder->build({ + source => 'Stockrotationstage', + value => { + branchcode_id => $library2->{branchcode}, + rota_id => $rota->{rota_id}, + } + }); + my $rota_id = $rota->{rota_id}; + my $stage1_id = $stage1->{stage_id}; + + # Unauthorized attempt to update + my $tx = $t->ua->build_tx( + PUT => "/api/v1/rotas/$rota_id/stages/$stage1_id/position" => + json => 2 + ); + $tx->req->cookies( + { name => 'CGISESSID', value => $unauthorized_session_id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx)->status_is(403); + + # Invalid attempt to move a stage on a non-existant rota + $tx = $t->ua->build_tx( + PUT => "/api/v1/rotas/99999999/stages/$stage1_id/position" => + json => 2 + ); + $tx->req->cookies( + { name => 'CGISESSID', value => $authorized_session_id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx)->status_is(404) + ->json_is( '/error' => "Not found - Invalid rota or stage ID" ); + + # Invalid attempt to move an non-existant stage + $tx = $t->ua->build_tx( + PUT => "/api/v1/rotas/$rota_id/stages/999999999/position" => + json => 2 + ); + $tx->req->cookies( + { name => 'CGISESSID', value => $authorized_session_id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx)->status_is(404) + ->json_is( '/error' => "Not found - Invalid rota or stage ID" ); + + # Invalid attempt to move stage to current position + my $curr_position = $stage1->{position}; + $tx = $t->ua->build_tx( + PUT => "/api/v1/rotas/$rota_id/stages/$stage1_id/position" => + json => $curr_position + ); + $tx->req->cookies( + { name => 'CGISESSID', value => $authorized_session_id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx)->status_is(400) + ->json_is( '/error' => "Bad request - new position invalid" ); + + # Invalid attempt to move stage to invalid position + $tx = $t->ua->build_tx( + PUT => "/api/v1/rotas/$rota_id/stages/$stage1_id/position" => + json => 99999999 + ); + $tx->req->cookies( + { name => 'CGISESSID', value => $authorized_session_id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx)->status_is(400) + ->json_is( '/error' => "Bad request - new position invalid" ); + + # Valid, authorised move + $tx = $t->ua->build_tx( + PUT => "/api/v1/rotas/$rota_id/stages/$stage1_id/position" => + json => 2 + ); + $tx->req->cookies( + { name => 'CGISESSID', value => $authorized_session_id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx)->status_is(200); + + $schema->storage->txn_rollback; +}; + +sub create_user_and_session { + + my $args = shift; + my $flags = ( $args->{authorized} ) ? $args->{authorized} : 0; + my $dbh = C4::Context->dbh; + + my $user = $builder->build( + { + source => 'Borrower', + value => { + flags => $flags + } + } + ); + + # Create a session for the authorized user + my $session = C4::Auth::get_session(''); + $session->param( 'number', $user->{borrowernumber} ); + $session->param( 'id', $user->{userid} ); + $session->param( 'ip', '127.0.0.1' ); + $session->param( 'lasttime', time() ); + $session->flush; + + if ( $args->{authorized} ) { + $dbh->do( " + INSERT INTO user_permissions (borrowernumber,module_bit,code) + VALUES (?,3,'parameters_remaining_permissions')", undef, + $user->{borrowernumber} ); + } + + return ( $user->{borrowernumber}, $session->id ); +} + +1; diff --git a/tools/stockrotation.pl b/tools/stockrotation.pl new file mode 100755 index 0000000000..19f71ddee0 --- /dev/null +++ b/tools/stockrotation.pl @@ -0,0 +1,526 @@ +#!/usr/bin/perl + +# Copyright 2016 PTFS-Europe Ltd +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +=head1 stockrotation.pl + + Script to handle stockrotation. Including rotas, their associated stages + and items + +=cut + +use Modern::Perl; +use CGI; + +use C4::Auth; +use C4::Context; +use C4::Output; + +use Koha::Libraries; +use Koha::StockRotationRotas; +use Koha::StockRotationItems; +use Koha::StockRotationStages; +use Koha::Item; +use Koha::Util::StockRotation qw(:ALL); + +my $input = new CGI; + +unless (C4::Context->preference('StockRotation')) { + # redirect to Intranet home if self-check is not enabled + print $input->redirect("/cgi-bin/koha/mainpage.pl"); + exit; +} + +my ( $template, $loggedinuser, $cookie ) = get_template_and_user( + { + template_name => 'tools/stockrotation.tt', + query => $input, + type => 'intranet', + authnotrequired => 0 + } +); + +# Grab all passed data +# 'our' since Plack changes the scoping +# of 'my' +our %params = $input->Vars(); + +my $op = $params{op}; + +if (!defined $op) { + + # No operation is supplied, we're just displaying the list of rotas + my $rotas = Koha::StockRotationRotas->search->as_list; + + $template->param( + existing_rotas => $rotas, + no_op_set => 1 + ); + +} elsif ($op eq 'create_edit_rota') { + + # Edit an existing rota or define a new one + my $rota_id = $params{rota_id}; + + my $rota = {}; + + if (!defined $rota_id) { + + # No ID supplied, we're creating a new rota + # Create a shell rota hashref + $rota = { + cyclical => 1, + active => 0 + }; + + } else { + + # ID supplied, we're editing an existing rota + $rota = Koha::StockRotationRotas->find($rota_id); + + } + + $template->param( + rota => $rota, + op => $op + ); + +} elsif ($op eq 'toggle_rota') { + + # Find and update the active status of the rota + my $rota = Koha::StockRotationRotas->find($params{rota_id}); + + my $new_active = ($rota->active == 1) ? 0 : 1; + + $rota->active($new_active)->store; + + # Return to rotas page + print $input->redirect('stockrotation.pl'); + +} elsif ($op eq 'process_rota') { + + # Get a hashref of the submitted rota data + my $rota = get_rota_from_form(); + + if (!process_rota($rota)) { + + # The submitted rota was invalid + $template->param( + error => 'invalid_form', + rota => $rota, + op => 'create_edit_rota' + ); + + } else { + + # All was well, return to the rotas list + print $input->redirect('stockrotation.pl'); + + } + +} elsif ($op eq 'manage_stages') { + + my $rota = Koha::StockRotationRotas->find($params{rota_id}); + + $template->param( + rota => $rota, + branches => get_branches(), + existing_stages => get_stages($rota), + rota_id => $params{rota_id}, + op => $op + ); + +} elsif ($op eq 'create_edit_stage') { + + # Edit an existing stage or define a new one + my $stage_id = $params{stage_id}; + + my $rota_id = $params{rota_id}; + + if (!defined $stage_id) { + + # No ID supplied, we're creating a new stage + $template->param( + branches => get_branches(), + stage => {}, + rota_id => $rota_id, + op => $op + ); + + } else { + + # ID supplied, we're editing an existing stage + my $stage = Koha::StockRotationStages->find($stage_id); + + $template->param( + branches => get_branches(), + stage => $stage, + rota_id => $stage->rota->rota_id, + op => $op + ); + + } + +} elsif ($op eq 'confirm_remove_from_rota') { + + # Get the stage we're deleting + $template->param( + op => $op, + rota_id => $params{rota_id}, + stage_id => $params{stage_id}, + item_id => $params{item_id} + ); + +} elsif ($op eq 'confirm_delete_stage') { + + # Get the stage we're deleting + my $stage = Koha::StockRotationStages->find($params{stage_id}); + + $template->param( + op => $op, + stage => $stage + ); + +} elsif ($op eq 'delete_stage') { + + # Get the stage we're deleting + my $stage = Koha::StockRotationStages->find($params{stage_id}); + + # Get the ID of the rota with which this stage is associated + # (so we can return to the "Manage stages" page after deletion) + my $rota_id = $stage->rota->rota_id; + + $stage->delete; + + # Return to the stages list + print $input->redirect("?op=manage_stages&rota_id=$rota_id"); + +} elsif ($op eq 'process_stage') { + + # Get a hashref of the submitted stage data + my $stage = get_stage_from_form(); + + # The rota we're managing + my $rota_id = $params{rota_id}; + + if (!process_stage($stage, $rota_id)) { + + # The submitted stage was invalid + # Get all branches + my $branches = get_branches(); + + $template->param( + error => 'invalid_form', + all_branches => $branches, + stage => $stage, + rota_id => $rota_id, + op => 'create_edit_stage' + ); + + } else { + + # All was well, return to the stages list + print $input->redirect("?op=manage_stages&rota_id=$rota_id"); + + } + +} elsif ($op eq 'manage_items') { + + my $rota = Koha::StockRotationRotas->find($params{rota_id}); + + # Get all items on this rota, for each prefetch their + # stage and biblio objects + my $items = Koha::StockRotationItems->search( + { 'stage.rota_id' => $params{rota_id} }, + { + prefetch => { + stage => { + 'stockrotationitems' => { + 'itemnumber' => 'biblionumber' + } + } + } + } + ); + + $template->param( + rota_id => $params{rota_id}, + error => $params{error}, + items => $items, + branches => get_branches(), + stages => get_stages($rota), + rota => $rota, + op => $op + ); + +} elsif ($op eq 'move_to_next_stage') { + + move_to_next_stage($params{item_id}, $params{stage_id}); + + # Return to the items list + print $input->redirect("?op=manage_items&rota_id=" . $params{rota_id}); + +} elsif ($op eq 'toggle_in_demand') { + + # Toggle the item's in_demand + toggle_indemand($params{item_id}, $params{stage_id}); + + # Return to the items list + print $input->redirect("?op=manage_items&rota_id=".$params{rota_id}); + +} elsif ($op eq 'remove_item_from_stage') { + + # Remove the item from the stage + remove_from_stage($params{item_id}, $params{stage_id}); + + # Return to the items list + print $input->redirect("?op=manage_items&rota_id=".$params{rota_id}); + +} elsif ($op eq 'add_items_to_rota') { + + # The item's barcode, + # which we may or may not have been passed + my $barcode = $params{barcode}; + + # The rota we're adding the item to + my $rota_id = $params{rota_id}; + + # The uploaded file filehandle, + # which we may or may not have been passed + my $barcode_file = $input->upload("barcodefile"); + + # We need to create an array of one or more barcodes to + # insert + my @barcodes = (); + + # If the barcode input box was populated, use it + push @barcodes, $barcode if $barcode; + + # Only parse the uploaded file if necessary + if ($barcode_file) { + + # Call binmode on the filehandle as we want to set a + # UTF-8 layer on it + binmode($barcode_file, ":encoding(UTF-8)"); + # Parse the file into an array of barcodes + while (my $barcode = <$barcode_file>) { + $barcode =~ s/\r/\n/g; + $barcode =~ s/\n+/\n/g; + my @data = split(/\n/, $barcode); + push @barcodes, @data; + } + + } + + # A hashref to hold the status of each barcode + my $barcode_status = { + ok => [], + on_other => [], + on_this => [], + not_found => [] + }; + + # If we have something to work with, do it + get_barcodes_status($rota_id, \@barcodes, $barcode_status) if (@barcodes); + + # Now we know the status of each barcode, add those that + # need it + if (scalar @{$barcode_status->{ok}} > 0) { + + add_items_to_rota($rota_id, $barcode_status->{ok}); + + } + # If we were only passed one barcode and it was successfully + # added, redirect back to ourselves, we don't want to display + # a report, redirect also if we were passed no barcodes + if ( + scalar @barcodes == 0 || + (scalar @barcodes == 1 && scalar @{$barcode_status->{ok}} == 1) + ) { + + print $input->redirect("?op=manage_items&rota_id=$rota_id"); + + } else { + + # Report on the outcome + $template->param( + barcode_status => $barcode_status, + rota_id => $rota_id, + op => $op + ); + + } + +} elsif ($op eq 'move_items_to_rota') { + + # The barcodes of the items we're moving + my @move = $input->param('move_item'); + + foreach my $item(@move) { + + # The item we're moving + my $item = Koha::Items->find($item); + + # Move it to the new rota + $item->add_to_rota($params{rota_id}); + + } + + # Return to the items list + print $input->redirect("?op=manage_items&rota_id=".$params{rota_id}); + +} + +output_html_with_http_headers $input, $cookie, $template->output; + +sub get_rota_from_form { + + return { + id => $params{id}, + title => $params{title}, + cyclical => $params{cyclical}, + active => $params{active}, + description => $params{description} + }; +} + +sub get_stage_from_form { + + return { + stage_id => $params{stage_id}, + branchcode => $params{branchcode}, + duration => $params{duration} + }; +} + +sub process_rota { + + my $sub_rota = shift; + + # Fields we require + my @required = ('title','cyclical','active'); + + # Count of the number of required fields we have + my $valid = 0; + + # Ensure we have everything we require + foreach my $req(@required) { + + if (exists $sub_rota->{$req}) { + + chomp(my $value = $sub_rota->{$req}); + if (length $value > 0) { + $valid++; + } + + } + + } + + # If we don't have everything we need + return 0 if $valid != scalar @required; + + # Passed validation + # Find the rota we're updating + my $rota = Koha::StockRotationRotas->find($sub_rota->{id}); + + if ($rota) { + + $rota->title( + $sub_rota->{title} + )->cyclical( + $sub_rota->{cyclical} + )->active( + $sub_rota->{active} + )->description( + $sub_rota->{description} + )->store; + + } else { + + $rota = Koha::StockRotationRota->new({ + title => $sub_rota->{title}, + cyclical => $sub_rota->{cyclical}, + active => $sub_rota->{active}, + description => $sub_rota->{description} + })->store; + + } + + return 1; +} + +sub process_stage { + + my ($sub_stage, $rota_id) = @_; + + # Fields we require + my @required = ('branchcode','duration'); + + # Count of the number of required fields we have + my $valid = 0; + + # Ensure we have everything we require + foreach my $req(@required) { + + if (exists $sub_stage->{$req}) { + + chomp(my $value = $sub_stage->{$req}); + if (length $value > 0) { + $valid++; + } + + } + + } + + # If we don't have everything we need + return 0 if $valid != scalar @required; + + # Passed validation + # Find the stage we're updating + my $stage = Koha::StockRotationStages->find($sub_stage->{stage_id}); + + if ($stage) { + + # Updating an existing stage + $stage->branchcode_id( + $sub_stage->{branchcode} + )->duration( + $sub_stage->{duration} + )->store; + + } else { + + # Creating a new stage + $stage = Koha::StockRotationStage->new({ + branchcode_id => $sub_stage->{branchcode}, + rota_id => $rota_id, + duration => $sub_stage->{duration} + })->store; + + } + + return 1; +} + +=head1 AUTHOR + +Andrew Isherwood <andrew.isherwood@ptfs-europe.com> + +=cut -- 2.14.2