c40fc6221f530cf07366ac5115d8d21226a146f8
[koha-equinox.git] / misc / cronjobs / stockrotation.pl
1 #!/usr/bin/perl
2
3 # Copyright 2016 PTFS Europe
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it under the
8 # terms of the GNU General Public License as published by the Free Software
9 # Foundation; either version 3 of the License, or (at your option) any later
10 # version.
11 #
12 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License along
17 # with Koha; if not, write to the Free Software Foundation, Inc.,
18 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19
20 =head1 NAME
21
22 stockrotation.pl
23
24 =head1 SYNOPSIS
25
26     --[a]dmin-email    An address to which email reports should also be sent
27     --[b]ranchcode     Select branch to report on for 'email' reports (default: all)
28     --e[x]ecute        Actually perform stockrotation housekeeping
29     --[r]eport         Select either 'full' or 'email'
30     --[S]end-all       Send email reports even if the report body is empty
31     --[s]end-email     Send reports by email
32     --[h]elp           Display this help message
33
34 Cron script implementing scheduled stockrotation functionality.
35
36 By default this script merely reports on the current status of the
37 stockrotation subsystem.  In order to actually place items in transit, the
38 script must be run with the `execute` argument.
39
40 `report` allows you to select the type of report that will be emitted. It's
41 set to 'full' by default.  If the `email` report is selected, you can use the
42 `branchcode` parameter to specify which branch's report you would like to see.
43 The default is 'all'.
44
45 `admin-email` is an additional email address to which we will send all email
46 reports in addition to sending them to branch email addresses.
47
48 `send-email` will cause the script to send reports by email, and `send-all`
49 will cause even reports with an empty body to be sent.
50
51 =head1 DESCRIPTION
52
53 This script is used to move items from one stockrotationstage to the next,
54 if they are elible for processing.
55
56 it should be run from cron like:
57
58    stockrotation.pl --report email --send-email --execute
59
60 Prior to that you can run the script from the command line without the
61 --execute and --send-email parameters to see what reports the script would
62 generate in 'production' mode.  This is immensely useful for testing, or for
63 getting to understand how the stockrotation module works: you can set up
64 different scenarios, and then "query" the system on what it would do.
65
66 Normally you would want to run this script once per day, probably around
67 midnight-ish to move any stockrotationitems along their rotas and to generate
68 the email reports for branch libraries.
69
70 Each library will receive a report with "items of interest" for them for
71 today's rota checks.  Each item there will be an item that should, according
72 to Koha, be located on the shelves of that branch, and which should be picked
73 up and checked in.  The item will either:
74 - have been placed in transit to their new stage library;
75 - have been placed in transit to be returned to their current stage library;
76 - have just been added to a rota and will already be at the correct library;
77
78 In the last case the item will be checked in and no message will pop up.  In
79 the other cases a message will pop up requesting the item be posted to their
80 new branch.
81
82 =head2 What does the --execute flag do?
83
84 To understand this, you will need to know a little bit about the design of
85 this script and the stockrotation modules.
86
87 This script operates in 3 phases: first it walks the graph of rotas, stages
88 and items.  For each active rota, it investigates the items in each stage and
89 determines whether action is required.  It does not perform any actions, it
90 just "sieves" all items on active rotas into "actionable" and "non-actionable"
91 baskets.  We can use these baskets to perform actions against the items, or to
92 generate reports.
93
94 During the second phase this script then loops through the actionable baskets,
95 and performs the relevant action (initiate, repatriate, advance) on each item.
96
97 Finally, during the third phase we revisit the original baskets and we compile
98 reports (for instance per branch email reports).
99
100 When the script is run without the "--execute" flag, we perform phase 1, skip
101 phase 2 and move straight onto phase 3.
102
103 With the "--execute" flag we also perform the database operations.
104
105 So with or without the flag, the report will look the same (except for the "No
106 database updates have been performed.").
107
108 =cut
109
110 use Modern::Perl;
111 use Getopt::Long qw/HelpMessage :config gnu_getopt/;
112
113 use Koha::Script -cron;
114 use C4::Context;
115 use C4::Letters;
116 use Koha::StockRotationRotas;
117
118 my $admin_email = '';
119 my $branch      = 0;
120 my $execute     = 0;
121 my $report      = 'full';
122 my $send_all    = 0;
123 my $send_email  = 0;
124
125 my $ok = GetOptions(
126     'admin-email|a=s' => \$admin_email,
127     'branchcode|b=s'  => sub {
128         my ( $opt_name, $opt_value ) = @_;
129         if ( $opt_value eq 'all' ) {
130             $branch = 0;
131         }
132         else {
133             my $branches = Koha::Libraries->search( {},
134                 { order_by => { -asc => 'branchname' } } );
135             my $brnch = $branches->find($opt_value);
136             if ($brnch) {
137                 $branch = $brnch;
138                 return $brnch;
139             }
140             else {
141                 printf("Option $opt_name should be one of (name -> code):\n");
142                 while ( my $candidate = $branches->next ) {
143                     printf( "  %-40s  ->  %s\n",
144                         $candidate->branchname, $candidate->branchcode );
145                 }
146                 exit 1;
147             }
148         }
149     },
150     'execute|x'  => \$execute,
151     'report|r=s' => sub {
152         my ( $opt_name, $opt_value ) = @_;
153         if ( $opt_value eq 'full' || $opt_value eq 'email' ) {
154             $report = $opt_value;
155         }
156         else {
157             printf("Option $opt_name should be either 'email' or 'full'.\n");
158             exit 1;
159         }
160     },
161     'send-all|S'   => \$send_all,
162     'send-email|s' => \$send_email,
163     'help|h|?'     => sub { HelpMessage }
164 );
165 exit 1 unless ($ok);
166
167 $send_email++ if ($send_all);    # if we send all, then we must want emails.
168
169 if ( $send_email && !$admin_email && ($report eq 'full')) {
170     printf("Sending the full report by email requires --admin-email.\n");
171     exit 1;
172 }
173
174 =head2 Helpers
175
176 =head3 execute
177
178   undef = execute($report);
179
180 Perform the database updates, within a transaction, that are reported as
181 needing to be performed by $REPORT.
182
183 $REPORT should be the return value of an invocation of `investigate`.
184
185 This procedure WILL mess with your database.
186
187 =cut
188
189 sub execute {
190     my ($data) = @_;
191
192     # Begin transaction
193     my $schema = Koha::Database->new->schema;
194     $schema->storage->txn_begin;
195
196     # Carry out db updates
197     foreach my $item ( @{ $data->{items} } ) {
198         my $reason = $item->{reason};
199         if ( $reason eq 'repatriation' ) {
200             $item->{object}->repatriate;
201         }
202         elsif ( grep { $reason eq $_ } qw/in-demand advancement initiation/ ) {
203             $item->{object}->advance;
204         }
205     }
206
207     # End transaction
208     $schema->storage->txn_commit;
209 }
210
211 =head3 report_full
212
213   my $full_report = report_full($report);
214
215 Return an arrayref containing a string containing a detailed report about the
216 current state of the stockrotation subsystem.
217
218 $REPORT should be the return value of `investigate`.
219
220 No data in the database is manipulated by this procedure.
221
222 =cut
223
224 sub report_full {
225     my ($data) = @_;
226
227     my $header = "";
228     my $body   = "";
229
230     # Summary
231     $header .= "STOCKROTATION REPORT\n";
232     $header .= "--------------------\n";
233     $body   .= sprintf "
234   Total number of rotas:         %5u
235     Inactive rotas:              %5u
236     Active rotas:                %5u
237   Total number of items:         %5u
238     Inactive items:              %5u
239     Stationary items:            %5u
240     Actionable items:            %5u
241   Total items to be initiated:   %5u
242   Total items to be repatriated: %5u
243   Total items to be advanced:    %5u
244   Total items in demand:         %5u\n\n",
245       $data->{sum_rotas},  $data->{rotas_inactive}, $data->{rotas_active},
246       $data->{sum_items},  $data->{items_inactive}, $data->{stationary},
247       $data->{actionable}, $data->{initiable},      $data->{repatriable},
248       $data->{advanceable}, $data->{indemand};
249
250     if ( @{ $data->{rotas} } ) {    # Per Rota details
251         $body .= "ROTAS DETAIL\n";
252         $body .= "------------\n\n";
253         foreach my $rota ( @{ $data->{rotas} } ) {
254             $body .= sprintf "Details for %s [%s]:\n",
255               $rota->{name}, $rota->{id};
256             $body .= "\n  Items:";    # Rota item details
257             if ( @{ $rota->{items} } ) {
258                 $body .=
259                   join( "", map { _print_item($_) } @{ $rota->{items} } );
260             }
261             else {
262                 $body .= "\n    No items to be processed for this rota.\n";
263             }
264             $body .= "\n  Log:";      # Rota log details
265             if ( @{ $rota->{log} } ) {
266                 $body .= join( "", map { _print_item($_) } @{ $rota->{log} } );
267             }
268             else {
269                 $body .= "\n    No items in log for this rota.\n\n";
270             }
271         }
272     }
273     return [
274         $header,
275         {
276             letter => {
277                 title   => 'Stockrotation Report',
278                 content => $body                     # The body of the report
279             },
280             status          => 1,    # We have a meaningful report
281             no_branch_email => 1,    # We don't expect branch email in report
282         }
283     ];
284 }
285
286 =head3 report_by_branch
287
288   my $email_report = report_by_branch($report, [$branch]);
289
290 Returns an arrayref containing a header string, with basic report information,
291 and any number of 'per_branch' strings, containing a detailed report about the
292 current state of the stockrotation subsystem, from the perspective of those
293 individual branches.
294
295 =over 2
296
297 =item $report should be the return value of `investigate`
298 =item $branch is optional and should be either 0 (to indicate 'all'), or a specific Koha::Library object.
299
300 =back
301
302 No data in the database is manipulated by this procedure.
303
304 =cut
305
306 sub report_by_branch {
307     my ( $data, $branch ) = @_;
308
309     my $out    = [];
310     my $header = "";
311
312     # Summary
313     my $branched = $data->{branched};
314     my $flag     = 0;
315
316     $header .= "BRANCH-BASED STOCKROTATION REPORT\n";
317     $header .= "---------------------------------\n";
318     push @{$out}, $header;
319
320     if ($branch) {    # Branch limited report
321         push @{$out}, _report_per_branch( $branched->{ $branch->branchcode } );
322     }
323     elsif ( $data->{actionable} ) {    # Full email report
324         while ( my ( $branchcode_id, $details ) = each %{$branched} ) {
325             push @{$out}, _report_per_branch($details)
326               if ( @{ $details->{items} } );
327         }
328     }
329     else {
330         push @{$out}, {
331             body => "No actionable items at any libraries.\n\n",    # The body of the report
332             no_branch_email => 1,    # We don't expect branch email in report
333         };
334     }
335     return $out;
336 }
337
338 =head3 _report_per_branch
339
340   my $branch_string = _report_per_branch($branch_details);
341
342 return a string containing details about the stockrotation items and their
343 status for the branch identified by $BRANCHCODE.
344
345 This helper procedure is only used from within `report_by_branch`.
346
347 No data in the database is manipulated by this procedure.
348
349 =cut
350
351 sub _report_per_branch {
352     my ($branch) = @_;
353
354     my $status = 0;
355     if ( $branch && @{ $branch->{items} } ) {
356         $status = 1;
357     }
358
359     if (
360         my $letter = C4::Letters::GetPreparedLetter(
361             module                 => 'circulation',
362             letter_code            => "SR_SLIP",
363             branchcode             => $branch->{code},
364             message_transport_type => 'email',
365             substitute             => { branch => $branch }
366         )
367       )
368     {
369         return {
370             letter        => $letter,
371             email_address => $branch->{email},
372             status        => $status
373         };
374     }
375     return;
376 }
377
378 =head3 _print_item
379
380   my $string = _print_item($item_section);
381
382 Return a string containing an overview about $ITEM_SECTION.
383
384 This helper procedure is only used from within `report_full`.
385
386 No data in the database is manipulated by this procedure.
387
388 =cut
389
390 sub _print_item {
391     my ($item) = @_;
392     return sprintf "
393     Title:           %s
394     Author:          %s
395     Callnumber:      %s
396     Location:        %s
397     Barcode:         %s
398     On loan?:        %s
399     Status:          %s
400     Current Library: %s [%s]\n\n",
401       $item->{title}      || "N/A", $item->{author}   || "N/A",
402       $item->{callnumber} || "N/A", $item->{location} || "N/A",
403       $item->{barcode} || "N/A", $item->{onloan} ? 'Yes' : 'No',
404       $item->{reason} || "N/A", $item->{branch}->branchname,
405       $item->{branch}->branchcode;
406 }
407
408 =head3 emit
409
410   undef = emit($params);
411
412 $PARAMS should be a hashref of the following format:
413   admin_email: the address to which a copy of all reports should be sent.
414   execute: the flag indicating whether we performed db updates
415   send_all: the flag indicating whether we should send even empty reports
416   send_email: the flag indicating whether we want to emit to stdout or email
417   report: the data structure returned from one of the report procedures
418
419 No data in the database is manipulated by this procedure.
420
421 The return value is unspecified: we simply emit a message as a side-effect or
422 die.
423
424 =cut
425
426 sub emit {
427     my ($params) = @_;
428
429 # REPORT is an arrayref of at least 2 elements:
430 #   - The header for the report, which will be repeated for each part
431 #   - a "part" for each report we want to emit
432 # PARTS are hashrefs:
433 #   - part->{status}: a boolean indicating whether the reported part is empty or not
434 #   - part->{email_address}: the email address to send the report to
435 #   - part->{no_branch_email}: a boolean indicating that we are missing a branch email
436 #   - part->{letter}: a GetPreparedLetter hash as returned by the C4::Letters module
437     my $report = $params->{report};
438     my $header = shift @{$report};
439     my $parts  = $report;
440
441     my @emails;
442     foreach my $part ( @{$parts} ) {
443
444         if ( $part->{status} || $params->{send_all} ) {
445
446             # We have a report to send, or we want to send even empty
447             # reports.
448
449             # Select email address to send to
450             my $addressee;
451             if ( $part->{email_address} ) {
452                 $addressee = $part->{email_address};
453             }
454             elsif ( !$part->{no_branch_email} ) {
455                 $addressee = C4::Context->preference('KohaAdminEmailAddress')
456                   if ( C4::Context->preference('KohaAdminEmailAddress') );
457             }
458
459             if ( $params->{send_email} ) {    # Only email if emails requested
460                 if ( defined($addressee) ) {
461                     C4::Letters::EnqueueLetter(
462                         {
463                             letter                 => $part->{letter},
464                             to_address             => $addressee,
465                             message_transport_type => 'email',
466                         }
467                       )
468                       or warn
469                       "can't enqueue letter $part->{letter} for $addressee";
470                 }
471
472                 # Copy to admin?
473                 if ( $params->{admin_email} ) {
474                     C4::Letters::EnqueueLetter(
475                         {
476                             letter                 => $part->{letter},
477                             to_address             => $params->{admin_email},
478                             message_transport_type => 'email',
479                         }
480                       )
481                       or warn
482                       "can't enqueue letter $part->{letter} for $params->{admin_email}";
483                 }
484             }
485             else {
486                 my $email =
487                   "-------- Email message --------" . "\n\n";
488                 $email .= "To: $addressee\n";
489                 $email .= "Cc: " . $params->{admin_email} . "\n"
490                   if ( $params->{admin_email} );
491                 $email .= "Subject: "
492                   . $part->{letter}->{title} . "\n\n"
493                   . $part->{letter}->{content};
494                 push @emails, $email;
495             }
496         }
497     }
498
499     # Emit to stdout instead of email?
500     if ( !$params->{send_email} ) {
501
502         # The final message is the header + body of this part.
503         my $msg = $header;
504         $msg .= "No database updates have been performed.\n\n"
505           unless ( $params->{execute} );
506
507         # Append email reports to message
508         $msg .= join( "\n\n", @emails );
509         printf $msg;
510     }
511 }
512
513 #### Main Code
514
515 # Compile Stockrotation Report data
516 my $rotas = Koha::StockRotationRotas->search(undef,{ order_by => { '-asc' => 'title' }});
517 my $data  = $rotas->investigate;
518
519 # Perform db updates if requested
520 execute($data) if ($execute);
521
522 # Emit Reports
523 my $out_report = {};
524 $out_report = report_by_branch( $data, $branch ) if $report eq 'email';
525 $out_report = report_full( $data, $branch ) if $report eq 'full';
526 emit(
527     {
528         admin_email => $admin_email,
529         execute     => $execute,
530         report      => $out_report,
531         send_all    => $send_all,
532         send_email  => $send_email,
533     }
534 );
535
536 =head1 AUTHOR
537
538 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
539
540 =cut