Bug 11897: Stockrotation
authorMartin Renvoize <martin.renvoize@ptfs-europe.com>
Mon, 1 Oct 2018 16:46:40 +0000 (17:46 +0100)
committerNick Clemens <nick@bywatersolutions.com>
Tue, 9 Oct 2018 15:46:05 +0000 (15:46 +0000)
The stock rotation feature adds a batch process to automate rotation of
catalgue items with a staff client page under tools to manage rotas/schedules.

Once a rota is configured, and your staff user has the right permissions
to allocate items, then an additional tab will appear on biblio records
allowing the management of of which rota, if any, individual items belong to.

It also includes a cron script to process the items on a daily basis.

Signed-off-by: Kathleen Milne <kathleen.milne@cne-siar.gov.uk>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>

Edit: I removed a temporary file

Signed-off-by: Nick Clemens <nick@bywatersolutions.com>

30 files changed:
Koha/Item.pm
Koha/Library.pm
Koha/REST/V1/Stage.pm [new file with mode: 0644]
Koha/StockRotationItem.pm [new file with mode: 0644]
Koha/StockRotationItems.pm [new file with mode: 0644]
Koha/StockRotationRota.pm [new file with mode: 0644]
Koha/StockRotationRotas.pm [new file with mode: 0644]
Koha/StockRotationStage.pm [new file with mode: 0644]
Koha/StockRotationStages.pm [new file with mode: 0644]
Koha/Util/StockRotation.pm [new file with mode: 0644]
api/v1/swagger/paths.json
api/v1/swagger/paths/rotas.json [new file with mode: 0644]
catalogue/stockrotation.pl [new file with mode: 0755]
koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss
koha-tmpl/intranet-tmpl/prog/en/includes/biblio-view-menu.inc
koha-tmpl/intranet-tmpl/prog/en/includes/permissions.inc
koha-tmpl/intranet-tmpl/prog/en/includes/stockrotation-toolbar.inc [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/includes/tools-menu.inc
koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/stockrotation.tt [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/modules/tools/stockrotation.tt [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/modules/tools/tools-home.tt
koha-tmpl/intranet-tmpl/prog/js/pages/stockrotation.js [new file with mode: 0644]
misc/cronjobs/stockrotation.pl [new file with mode: 0755]
t/db_dependent/Items.t
t/db_dependent/Koha/Libraries.t
t/db_dependent/StockRotationItems.t [new file with mode: 0644]
t/db_dependent/StockRotationRotas.t [new file with mode: 0644]
t/db_dependent/StockRotationStages.t [new file with mode: 0644]
t/db_dependent/api/v1/stockrotationstage.t [new file with mode: 0644]
tools/stockrotation.pl [new file with mode: 0755]

index 964359e..721c0d6 100644 (file)
@@ -31,6 +31,8 @@ use Koha::Item::Transfer::Limits;
 use Koha::Item::Transfers;
 use Koha::Patrons;
 use Koha::Libraries;
+use Koha::StockRotationItem;
+use Koha::StockRotationRotas;
 
 use base qw(Koha::Object);
 
@@ -282,7 +284,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
 
index 9e6d616..7877174 100644 (file)
@@ -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 (file)
index 0000000..485384a
--- /dev/null
@@ -0,0 +1,60 @@
+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;
+
+=head1 NAME
+
+Koha::REST::V1::Stage
+
+=head2 Operations
+
+=head3 move
+
+Move a stage up or down the stockrotation rota.
+
+=cut
+
+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 (file)
index 0000000..e451bb0
--- /dev/null
@@ -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 (file)
index 0000000..c78c267
--- /dev/null
@@ -0,0 +1,128 @@
+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';
+}
+
+=head3 object_class
+
+=cut
+
+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 (file)
index 0000000..fdf52fc
--- /dev/null
@@ -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 (file)
index 0000000..ba905ff
--- /dev/null
@@ -0,0 +1,105 @@
+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';
+}
+
+=head3 object_class
+
+=cut
+
+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 (file)
index 0000000..dc76ba8
--- /dev/null
@@ -0,0 +1,419 @@
+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->branchreplyto
+              ? $stagebranch->branchreplyto
+              : $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 => $stagebranch->branchreplyto
+                  ? $stagebranch->branchreplyto
+                  : $stagebranch->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 => $stagebranch->branchreplyto
+                  ? $stagebranch->branchreplyto
+                  : $stagebranch->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 (file)
index 0000000..1b85999
--- /dev/null
@@ -0,0 +1,90 @@
+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';
+}
+
+=head3 object_class
+
+=cut
+
+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 (file)
index 0000000..13c78e5
--- /dev/null
@@ -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
index c9d8e88..c8870db 100644 (file)
@@ -34,5 +34,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 (file)
index 0000000..0cdda30
--- /dev/null
@@ -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 (executable)
index 0000000..8b6a324
--- /dev/null
@@ -0,0 +1,179 @@
+#!/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,
+            stockrotation => 'manage_rota_items',
+        },
+    }
+);
+
+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,
+            order_by => 'title'
+        }
+    );
+
+    # 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
index 6be1cf7..8fc9058 100644 (file)
@@ -4032,6 +4032,127 @@ span {
     width: 100% !important;
 }
 
+#stockrotation {
+    h3 {
+        margin: 30px 0 10px 0;
+    }
+    .dialog {
+        h3 {
+            margin: 10px 0;
+        }
+        margin-bottom: 20px;
+    }
+    .highlight_stage {
+        font-weight: bold;
+    }
+}
+
+#catalog_stockrotation .highlight_stage {
+    font-weight: bold;
+}
+
+#stockrotation {
+    #rota_form {
+        textarea {
+            width: 300px;
+            height: 100px;
+        }
+        #name {
+            width: 300px;
+        }
+        fieldset {
+            width: auto;
+        }
+    }
+    #stage_form fieldset, #add_rota_item_form fieldset {
+        width: auto;
+    }
+    .dialog.alert {
+        ul {
+            margin: 20px 0;
+        }
+        li {
+            list-style-type: none;
+        }
+    }
+}
+
+#catalog_stockrotation {
+    .item_select_rota {
+        vertical-align: middle;
+    }
+    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;
+    span {
+        padding: 3px;
+    }
+}
+
+#manage_stages {
+    ul {
+        padding-left: 0;
+    }
+    li {
+        list-style: none;
+        margin-bottom: 5px;
+        span {
+            padding: 6px 3px;
+        }
+    }
+    .stagename {
+        width: 15em;
+        display: inline-block;
+    }
+    .stageduration {
+        width: 10em;
+        display: inline-block;
+    }
+    .stageactions {
+        display: inline-block;
+    }
+    li:nth-child(odd) {
+        background-color: #F3F3F3;
+    }
+    .drag_handle {
+        margin-right: 6px;
+        cursor: move;
+    }
+    .drag_placeholder {
+        height: 2em;
+        border: 1px dotted #aaa;
+    }
+    h3 {
+        display: inline-block;
+    }
+    #ajax_status {
+        display: inline-block;
+        border: 1px solid #bcbcbc;
+        border-radius: 5px;
+        padding: 5px;
+        margin-left: 10px;
+        background: #f3f3f3;
+    }
+    #manage_stages_help {
+        margin: 20px 0;
+    }
+}
 
 #helper {
     span {
index 9f5c0cb..29f8d67 100644 (file)
@@ -40,5 +40,6 @@
 [% 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&amp;modules=CATALOGUING&amp;action=MODIFY&amp;object=[% biblio_object_id | url  %]">Modification log</a> </li>[% END %]
+[% IF ( CAN_user_stockrotation_manage_rota_items && 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>
index 4acae35..0620310 100644 (file)
   [%# self_check %]
     [%- CASE 'self_checkin_module' -%]<span>Log into the self check-in module. Note: this permission prevents the patron from using any other OPAC functionality</span>
     [%- CASE 'self_checkout_module' -%]<span>Perform self checkout at the OPAC. It should be used for the patron matching the AutoSelfCheckID</span>
+    [%- CASE 'manage_rota_items' -%]<span>Add and remove items from rotas</span>
+    [%- CASE 'manage_rotas' -%]<span>Create, edit and delete rotas</span>
   [%- END -%]
-    [%- CASE 'can_add_items_rotas' -%]<span>Add and remove items from rotas</span>
-    [%- CASE 'can_edit_rotas' -%]<span>Create, edit and delete rotas</span>
-    [%- END -%]
 [%- END -%]
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 (file)
index 0000000..f574b19
--- /dev/null
@@ -0,0 +1,12 @@
+[% 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="editrota" class="btn btn-default btn-sm" href="/cgi-bin/koha/tools/stockrotation.pl?op=create_edit_rota&amp;rota_id=[% rota_id %]"><i class="fa fa-pencil"></i> Edit rota</a>
+    [% END %]
+    [% IF op == 'manage_items' %]
+        <a id="editrota" class="btn btn-default btn-sm" href="/cgi-bin/koha/tools/stockrotation.pl?op=create_edit_rota&amp;rota_id=[% rota_id %]"><i class="fa fa-pencil"></i> Edit rota</a>
+    [% END %]
+</div>
index d3e03a9..bef464c 100644 (file)
@@ -1,3 +1,5 @@
+[% USE Koha %]
+
 <div id="navmenu">
 <div id="navmenulist">
 <ul>
@@ -38,6 +40,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_manage_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/catalogue/stockrotation.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/stockrotation.tt
new file mode 100644 (file)
index 0000000..5d6238d
--- /dev/null
@@ -0,0 +1,171 @@
+[% USE Koha %]
+[% USE Branches %]
+[% INCLUDE 'doc-head-open.inc' %]
+<title>Koha &rsaquo; Catalog &rsaquo; Stock rotation details for [% biblio.title %]</title>
+[% INCLUDE 'doc-head-close.inc' %]
+[% INCLUDE 'browser-strings.inc' %]
+[% Asset.js("js/browser.js") %]
+<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> &rsaquo; <a href="/cgi-bin/koha/catalogue/search.pl">Catalog</a>  &rsaquo; 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>Callnumber</th>
+                        <th>Rota</th>
+                        <th>Rota status</th>
+                        <th>In transit</th>
+                        <th>Stages &amp; duration in days<br>(current stage highlighted)</th>
+                        <th>&nbsp;</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 %]
+                                        [% IF item.rota.active %]
+                                            Active
+                                        [% ELSE %]
+                                            Inactive
+                                        [% END %]
+                                    [% IF !item.rota.active %]
+                                        </span>
+                                    [% END %]
+                                [% END %]
+                            </td>
+                            <td>
+                                [% IF item.bib_item.get_transfer %]
+                                    Yes
+                                [% ELSE %]
+                                    No
+                                [% END %]
+                            </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>
+                                    &raquo;
+                                [% END %]
+                                [% IF item.stages.size > 0 %]
+                                    <span class="stage">
+                                        [% IF item.rota.cyclical %]
+                                            START
+                                        [% ELSE %]
+                                            END
+                                        [% 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&amp;stage_id=[% item.stockrotationitem.stage.stage_id %]&amp;item_id=[% item.bib_item.id %]&amp;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&amp;stage_id=[% item.stockrotationitem.stage.stage_id %]&amp;item_id=[% item.bib_item.id %]&amp;biblionumber=[% biblionumber %]">
+                                    [% ELSE %]
+                                        <a class="btn btn-default btn-xs" disabled>
+                                    [% END %]
+                                        <i class="fa fa-fire"></i>
+                                        [% IF item.stockrotationitem.indemand %]
+                                            Remove "In demand"
+                                        [% ELSE %]
+                                            Add "In demand"
+                                        [% END %]
+                                    </a>
+                                    [% IF !in_transit %]
+                                        <a class="btn btn-default btn-xs" href="?op=confirm_remove_from_rota&amp;stage_id=[% item.stockrotationitem.stage.stage_id %]&amp;item_id=[% item.bib_item.id %]&amp;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&amp;stage_id=[% stage_id %]&amp;item_id=[% item_id %]&amp;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 (file)
index 0000000..9dec3d9
--- /dev/null
@@ -0,0 +1,510 @@
+[% USE Koha %]
+[% USE Branches %]
+[% USE KohaDates %]
+[% INCLUDE 'doc-head-open.inc' %]
+<title>Koha &rsaquo; Stock rotation</title>
+[% INCLUDE 'doc-head-close.inc' %]
+[% Asset.css("css/datatables.css") %]
+[% INCLUDE 'datatables.inc' %]
+[% Asset.js("js/pages/stockrotation.js") %]
+<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>
+    &rsaquo; <a href="/cgi-bin/koha/tools/tools-home.pl">Tools</a>
+
+[% IF no_op_set %]
+    &rsaquo; Stock rotation
+[% ELSE %]
+    &rsaquo; <a href="/cgi-bin/koha/tools/stockrotation.pl">Stock rotation</a>
+[% END %]
+
+[% IF (op == 'create_edit_rota' && rota.rota_id) %]
+    &rsaquo; Edit rota
+[% ELSIF (op == 'create_edit_rota' && !rota.rota_id) %]
+    &rsaquo; Create rota
+[% ELSIF (op == 'manage_stages') %]
+    &rsaquo; Manage stages
+[% ELSIF (op == 'create_edit_stage' && stage.id) %]
+    <a href="?op=manage_stages&amp;rota_id=[% rota_id %]">&rsaquo; Manage stages</a>
+    &rsaquo; Edit stage
+[% ELSIF (op == 'create_edit_stage' && !stage.id) %]
+    <a href="?op=manage_stages&amp;rota_id=[% rota_id %]">&rsaquo; Manage stages</a>
+    &rsaquo; Create stage
+[% ELSIF (op == 'manage_items') %]
+    &rsaquo; Manage items
+[% END %]
+
+</div>
+
+<div id="doc3" class="yui-t2">
+    <div id="bd">
+        <div id="yui-main">
+            <div id="stockrotation" class="yui-b">
+
+                [% IF no_op_set %]
+
+                    [% INCLUDE 'stockrotation-toolbar.inc' %]
+
+                    <h2>Stock rotation</h2>
+
+                    [% IF existing_rotas.size > 0 %]
+                        <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>&nbsp;</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&amp;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&amp;rota_id=[% rota.rota_id %]">Stages</a></li>
+                                                    [% IF CAN_user_stockrotation_manage_rota_items && rota.stockrotationstages.count > 0 %]
+                                                    <li><a href="?op=manage_items&amp;rota_id=[% rota.rota_id %]">Items</a></li>
+                                                    [% END %]
+                                                </ul>
+                                            </div>
+                                            <a class="btn btn-default btn-xs" href="?op=toggle_rota&amp;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 %]
+                        <h2>Edit "[% rota.title %]"</h2>
+                    [% ELSE %]
+                        <h2>Create new rota</h2>
+                    [% 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="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 error == 'invalid_form' %]
+                    <div class="dialog alert">
+                        <h3>There was a problem with your form submission</h3>
+                    </div>
+                    [% END %]
+
+                    <h2>Manage [% rota.title %] stages</h2>
+                    <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>
+
+                    <form id="stage_form" method="post" enctype="multipart/form-data" class="validated">
+                        <fieldset class="rows">
+                            <legend>Add stage</legend>
+                            <ol>
+                                <li>
+                                    <label class="required" for="branch">Library:</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="Submit">
+                        </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>
+
+                    [% IF existing_stages.size > 0 %]
+                        <div id="manage_stages">
+                            <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">Library</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&amp;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&amp;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 %]
+                        <h2>Edit "[% Branches.GetName(stage.branchcode_id) %]"</h2>
+                    [% ELSE %]
+                        <h2>Create new stage</h2>
+                    [% 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">Library:</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&amp;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&amp;item_id=[% item_id %]&amp;stage_id=[% stage_id %]&amp;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&amp;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&amp;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&amp;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 %]
+
+                    <h2>Manage [% rota.title %] items</h2>
+
+                    <div>
+                        <form id="add_rota_item_form" method="post" enctype="multipart/form-data" class="validated">
+                            <fieldset class="rows">
+                                <legend>Add item to &quot;[% rota.title %]&quot;</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 &quot;[% rota.title %]&quot;</h3>
+                        <table id="sr_manage_items" class="items_table" role="grid">
+                            <thead>
+                                <th>Barcode</th>
+                                <th>Title</th>
+                                <th>Author</th>
+                                <th>Callnumber</th>
+                                <th class="NoSearch">In transit</th>
+                                <th class="NoSort">Stages &amp; duration in days<br>(current stage highlighted)</th>
+                                <th class="NoSort">&nbsp;</th>
+                            </thead>
+                            <tbody>
+                                [% FOREACH item IN items %]
+                                    <tr>
+                                        <td><a href="/cgi-bin/koha/catalogue/moredetail.pl?itemnumber=[% item.id %]&amp;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>
+                                                &raquo;
+                                            [% 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&amp;rota_id=[% rota.id %]&amp;item_id=[% item.id %]&amp;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&amp;stage_id=[% item.stage.stage_id %]&amp;item_id=[% item.id %]&amp;rota_id=[% rota.id %]">
+                                            [% ELSE %]
+                                                <a class="btn btn-default btn-xs" disabled>
+                                            [% END %]
+                                                <i class="fa fa-fire"></i>
+                                                [% item.indemand ? 'Remove &quot;In demand&quot;' : 'Add &quot;In demand&quot;' %]
+                                            </a>
+                                            [% IF !in_transit %]
+                                                <a class="btn btn-default btn-xs" href="?op=confirm_remove_from_rota&amp;stage_id=[% item.stage.stage_id %]&amp;item_id=[% item.id %]&amp;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 &quot;[% item_on_other.stockrotationitem.stage.rota.title %]&quot;)</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&amp;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' %]
index a035ee1..3b3cf01 100644 (file)
@@ -1,3 +1,5 @@
+[% USE Koha %]
+
 [% INCLUDE 'doc-head-open.inc' %]
 <title>Koha &rsaquo; Tools</title>
 [% INCLUDE 'doc-head-close.inc' %]
     <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_manage_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 (file)
index 0000000..2763945
--- /dev/null
@@ -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 (executable)
index 0000000..a6cd0c1
--- /dev/null
@@ -0,0 +1,528 @@
+#!/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 C4::Context;
+use C4::Letters;
+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
+--------------------\n";
+    $body .= sprintf "
+  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,
+        {
+            letter => {
+                title   => 'Stockrotation Report',
+                content => $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 } );
+    }
+    elsif ( $data->{actionable} ) {    # Full email report
+        while ( my ( $branchcode_id, $details ) = each %{$branched} ) {
+            push @{$out}, _report_per_branch($details)
+              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 ($branch) = @_;
+
+    my $status = 0;
+    if ( $branch && @{ $branch->{items} } ) {
+        $status = 1;
+    }
+
+    if (
+        my $letter = C4::Letters::GetPreparedLetter(
+            module                 => 'circulation',
+            letter_code            => "SR_SLIP",
+            message_transport_type => 'email',
+            substitute             => $branch
+        )
+      )
+    {
+        return {
+            letter        => $letter,
+            email_address => $branch->{email},
+            $status
+        };
+    }
+    return;
+}
+
+=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_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->{status}: a boolean indicating whether the reported part is empty or not
+#   - part->{email_address}: the email address to send the report to
+#   - part->{no_branch_email}: a boolean indicating that we are missing a branch email
+#   - part->{letter}: a GetPreparedLetter hash as returned by the C4::Letters module
+    my $report = $params->{report};
+    my $header = shift @{$report};
+    my $parts  = $report;
+
+    my @emails;
+    foreach my $part ( @{$parts} ) {
+
+        if ( $part->{status} || $params->{send_all} ) {
+
+            # We have a report to send, or we want to send even empty
+            # reports.
+
+            # Send to branch
+            my $addressee;
+            if ( $part->{email_address} ) {
+                $addressee = $part->{email_address};
+            }
+            elsif ( !$part->{no_branch_email} ) {
+
+#push @emails, "***We tried to send a branch report, but we have no email address for this branch.***\n\n";
+                $addressee = C4::Context->preference('KohaAdminEmailAddress')
+                  if ( C4::Context->preference('KohaAdminEmailAddress') );
+            }
+
+            if ( $params->{send_email} ) {    # Only email if emails requested
+                if ( defined($addressee) ) {
+                    C4::Letters::EnqueueLetter(
+                        {
+                            letter                 => $part->{letter},
+                            to_address             => $addressee,
+                            message_transport_type => 'email',
+                        }
+                      )
+                      or warn
+                      "can't enqueue letter $part->{letter} for $addressee";
+                }
+
+                # Copy to admin?
+                if ( $params->{admin_email} ) {
+                    C4::Letters::EnqueueLetter(
+                        {
+                            letter                 => $part->{letter},
+                            to_address             => $params->{admin_email},
+                            message_transport_type => 'email',
+                        }
+                      )
+                      or warn
+"can't enqueue letter $part->{letter} for $params->{admin_email}";
+                }
+            }
+            else {
+                my $email =
+                  "-------- Email message --------" . "\n\n" . "To: "
+                  . defined($addressee)               ? $addressee
+                  : defined( $params->{admin_email} ) ? $params->{admin_email}
+                  : '' . "\n"
+                  . "Subject: "
+                  . $part->{letter}->{title} . "\n\n"
+                  . $part->{letter}->{content};
+                push @emails, $email;
+            }
+        }
+    }
+
+    # Emit to stdout instead of email?
+    if ( !$params->{send_email} ) {
+
+        # The final message is the header + body of this part.
+        my $msg = $header;
+        $msg .= "No database updates have been performed.\n\n"
+          unless ( $params->{execute} );
+
+        # Append email reports to message
+        $msg .= join( "\n\n", @emails );
+        printf $msg;
+    }
+}
+
+#### Main Code
+
+# Compile Stockrotation Report data
+my $rotas = Koha::StockRotationRotas->search(undef,{ order_by => { '-asc' => 'title' }});
+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
index 92151d6..a328735 100755 (executable)
@@ -830,6 +830,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;
 };
index d24ba3d..ef6a10b 100644 (file)
@@ -19,7 +19,7 @@
 
 use Modern::Perl;
 
-use Test::More tests => 4;
+use Test::More tests => 6;
 
 use Koha::Library;
 use Koha::Libraries;
@@ -53,6 +53,29 @@ 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' );
 
+# 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 (file)
index 0000000..2d9bf89
--- /dev/null
@@ -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 (file)
index 0000000..f1b2aa6
--- /dev/null
@@ -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 (file)
index 0000000..e6ea693
--- /dev/null
@@ -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 (file)
index 0000000..c76e9b1
--- /dev/null
@@ -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 (executable)
index 0000000..a5a064a
--- /dev/null
@@ -0,0 +1,531 @@
+#!/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',
+        flagsrequired   => {
+            tools => '*',
+            stockrotation => '*',
+        },
+        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(
+        undef,
+        {
+            order_by => { -asc => 'title' }
+        }
+    )->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
+        };
+
+    } 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},
+        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');
+
+    # 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}
+        )->description(
+            $sub_rota->{description}
+        )->store;
+
+    } else {
+
+        $rota = Koha::StockRotationRota->new({
+            title       => $sub_rota->{title},
+            cyclical    => $sub_rota->{cyclical},
+            active      => 0,
+            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