Bug 21620: Prevent stockrotation cronjob failures
[koha.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 use C4::Context;
113 use C4::Letters;
114 use Koha::StockRotationRotas;
115
116 my $admin_email = '';
117 my $branch      = 0;
118 my $execute     = 0;
119 my $report      = 'full';
120 my $send_all    = 0;
121 my $send_email  = 0;
122
123 my $ok = GetOptions(
124     'admin-email|a=s' => \$admin_email,
125     'branchcode|b=s'  => sub {
126         my ( $opt_name, $opt_value ) = @_;
127         my $branches = Koha::Libraries->search( {},
128             { order_by => { -asc => 'branchname' } } );
129         my $brnch = $branches->find($opt_value);
130         if ($brnch) {
131             $branch = $brnch;
132             return $brnch;
133         }
134         else {
135             printf("Option $opt_name should be one of (name -> code):\n");
136             while ( my $candidate = $branches->next ) {
137                 printf( "  %-40s  ->  %s\n",
138                     $candidate->branchname, $candidate->branchcode );
139             }
140             exit 1;
141         }
142     },
143     'execute|x'  => \$execute,
144     'report|r=s' => sub {
145         my ( $opt_name, $opt_value ) = @_;
146         if ( $opt_value eq 'full' || $opt_value eq 'email' ) {
147             $report = $opt_value;
148         }
149         else {
150             printf("Option $opt_name should be either 'email' or 'full'.\n");
151             exit 1;
152         }
153     },
154     'send-all|S'   => \$send_all,
155     'send-email|s' => \$send_email,
156     'help|h|?'     => sub { HelpMessage }
157 );
158 exit 1 unless ($ok);
159
160 $send_email++ if ($send_all);    # if we send all, then we must want emails.
161
162 =head2 Helpers
163
164 =head3 execute
165
166   undef = execute($report);
167
168 Perform the database updates, within a transaction, that are reported as
169 needing to be performed by $REPORT.
170
171 $REPORT should be the return value of an invocation of `investigate`.
172
173 This procedure WILL mess with your database.
174
175 =cut
176
177 sub execute {
178     my ($data) = @_;
179
180     # Begin transaction
181     my $schema = Koha::Database->new->schema;
182     $schema->storage->txn_begin;
183
184     # Carry out db updates
185     foreach my $item ( @{ $data->{items} } ) {
186         my $reason = $item->{reason};
187         if ( $reason eq 'repatriation' ) {
188             $item->{object}->repatriate;
189         }
190         elsif ( grep { $reason eq $_ } qw/in-demand advancement initiation/ ) {
191             $item->{object}->advance;
192         }
193     }
194
195     # End transaction
196     $schema->storage->txn_commit;
197 }
198
199 =head3 report_full
200
201   my $full_report = report_full($report);
202
203 Return an arrayref containing a string containing a detailed report about the
204 current state of the stockrotation subsystem.
205
206 $REPORT should be the return value of `investigate`.
207
208 No data in the database is manipulated by this procedure.
209
210 =cut
211
212 sub report_full {
213     my ($data) = @_;
214
215     my $header = "";
216     my $body   = "";
217
218     # Summary
219     $header .= "STOCKROTATION REPORT\n";
220     $header .= "--------------------\n";
221     $body .= sprintf "
222   Total number of rotas:         %5u
223     Inactive rotas:              %5u
224     Active rotas:                %5u
225   Total number of items:         %5u
226     Inactive items:              %5u
227     Stationary items:            %5u
228     Actionable items:            %5u
229   Total items to be initiated:   %5u
230   Total items to be repatriated: %5u
231   Total items to be advanced:    %5u
232   Total items in demand:         %5u\n\n",
233       $data->{sum_rotas},  $data->{rotas_inactive}, $data->{rotas_active},
234       $data->{sum_items},  $data->{items_inactive}, $data->{stationary},
235       $data->{actionable}, $data->{initiable},      $data->{repatriable},
236       $data->{advanceable}, $data->{indemand};
237
238     if ( @{ $data->{rotas} } ) {    # Per Rota details
239         $body .= "ROTAS DETAIL\n";
240         $body .= "------------\n\n";
241         foreach my $rota ( @{ $data->{rotas} } ) {
242             $body .= sprintf "Details for %s [%s]:\n",
243               $rota->{name}, $rota->{id};
244             $body .= "\n  Items:";    # Rota item details
245             if ( @{ $rota->{items} } ) {
246                 $body .=
247                   join( "", map { _print_item($_) } @{ $rota->{items} } );
248             }
249             else {
250                 $body .= "\n    No items to be processed for this rota.\n";
251             }
252             $body .= "\n  Log:";      # Rota log details
253             if ( @{ $rota->{log} } ) {
254                 $body .= join( "", map { _print_item($_) } @{ $rota->{log} } );
255             }
256             else {
257                 $body .= "\n    No items in log for this rota.\n\n";
258             }
259         }
260     }
261     return [
262         $header,
263         {
264             letter => {
265                 title   => 'Stockrotation Report',
266                 content => $body                     # The body of the report
267             },
268             status          => 1,    # We have a meaningful report
269             no_branch_email => 1,    # We don't expect branch email in report
270         }
271     ];
272 }
273
274 =head3 report_email
275
276   my $email_report = report_email($report);
277
278 Returns an arrayref containing a header string, with basic report information,
279 and any number of 'per_branch' strings, containing a detailed report about the
280 current state of the stockrotation subsystem, from the perspective of those
281 individual branches.
282
283 $REPORT should be the return value of `investigate`, and $BRANCH should be
284 either 0 (to indicate 'all'), or a specific Koha::Library object.
285
286 No data in the database is manipulated by this procedure.
287
288 =cut
289
290 sub report_email {
291     my ( $data, $branch ) = @_;
292
293     my $out    = [];
294     my $header = "";
295
296     # Summary
297     my $branched = $data->{branched};
298     my $flag     = 0;
299
300     $header .= "BRANCH-BASED STOCKROTATION REPORT\n";
301     $header .= "---------------------------------\n";
302     push @{$out}, $header;
303
304     if ($branch) {    # Branch limited report
305         push @{$out}, _report_per_branch( $branched->{ $branch->branchcode } );
306     }
307     elsif ( $data->{actionable} ) {    # Full email report
308         while ( my ( $branchcode_id, $details ) = each %{$branched} ) {
309             push @{$out}, _report_per_branch($details)
310               if ( @{ $details->{items} } );
311         }
312     }
313     else {
314         push @{$out}, {
315             body => "No actionable items at any libraries.\n\n",    # The body of the report
316             no_branch_email => 1,    # We don't expect branch email in report
317         };
318     }
319     return $out;
320 }
321
322 =head3 _report_per_branch
323
324   my $branch_string = _report_per_branch($branch_details, $branchcode, $branchname);
325
326 return a string containing details about the stockrotation items and their
327 status for the branch identified by $BRANCHCODE.
328
329 This helper procedure is only used from within `report_email`.
330
331 No data in the database is manipulated by this procedure.
332
333 =cut
334
335 sub _report_per_branch {
336     my ($branch) = @_;
337
338     my $status = 0;
339     if ( $branch && @{ $branch->{items} } ) {
340         $status = 1;
341     }
342
343     if (
344         my $letter = C4::Letters::GetPreparedLetter(
345             module                 => 'circulation',
346             letter_code            => "SR_SLIP",
347             message_transport_type => 'email',
348             substitute             => $branch
349         )
350       )
351     {
352         return {
353             letter        => $letter,
354             email_address => $branch->{email},
355             $status
356         };
357     }
358     return;
359 }
360
361 =head3 _print_item
362
363   my $string = _print_item($item_section);
364
365 Return a string containing an overview about $ITEM_SECTION.
366
367 This helper procedure is only used from within `report_full`.
368
369 No data in the database is manipulated by this procedure.
370
371 =cut
372
373 sub _print_item {
374     my ($item) = @_;
375     return sprintf "
376     Title:           %s
377     Author:          %s
378     Callnumber:      %s
379     Location:        %s
380     Barcode:         %s
381     On loan?:        %s
382     Status:          %s
383     Current Library: %s [%s]\n\n",
384       $item->{title}      || "N/A", $item->{author}   || "N/A",
385       $item->{callnumber} || "N/A", $item->{location} || "N/A",
386       $item->{barcode} || "N/A", $item->{onloan} ? 'Yes' : 'No',
387       $item->{reason} || "N/A", $item->{branch}->branchname,
388       $item->{branch}->branchcode;
389 }
390
391 =head3 emit
392
393   undef = emit($params);
394
395 $PARAMS should be a hashref of the following format:
396   admin_email: the address to which a copy of all reports should be sent.
397   execute: the flag indicating whether we performed db updates
398   send_all: the flag indicating whether we should send even empty reports
399   send_email: the flag indicating whether we want to emit to stdout or email
400   report: the data structure returned from one of the report procedures
401
402 No data in the database is manipulated by this procedure.
403
404 The return value is unspecified: we simply emit a message as a side-effect or
405 die.
406
407 =cut
408
409 sub emit {
410     my ($params) = @_;
411
412 # REPORT is an arrayref of at least 2 elements:
413 #   - The header for the report, which will be repeated for each part
414 #   - a "part" for each report we want to emit
415 # PARTS are hashrefs:
416 #   - part->{status}: a boolean indicating whether the reported part is empty or not
417 #   - part->{email_address}: the email address to send the report to
418 #   - part->{no_branch_email}: a boolean indicating that we are missing a branch email
419 #   - part->{letter}: a GetPreparedLetter hash as returned by the C4::Letters module
420     my $report = $params->{report};
421     my $header = shift @{$report};
422     my $parts  = $report;
423
424     my @emails;
425     foreach my $part ( @{$parts} ) {
426
427         if ( $part->{status} || $params->{send_all} ) {
428
429             # We have a report to send, or we want to send even empty
430             # reports.
431
432             # Send to branch
433             my $addressee;
434             if ( $part->{email_address} ) {
435                 $addressee = $part->{email_address};
436             }
437             elsif ( !$part->{no_branch_email} ) {
438
439 #push @emails, "***We tried to send a branch report, but we have no email address for this branch.***\n\n";
440                 $addressee = C4::Context->preference('KohaAdminEmailAddress')
441                   if ( C4::Context->preference('KohaAdminEmailAddress') );
442             }
443
444             if ( $params->{send_email} ) {    # Only email if emails requested
445                 if ( defined($addressee) ) {
446                     C4::Letters::EnqueueLetter(
447                         {
448                             letter                 => $part->{letter},
449                             to_address             => $addressee,
450                             message_transport_type => 'email',
451                         }
452                       )
453                       or warn
454                       "can't enqueue letter $part->{letter} for $addressee";
455                 }
456
457                 # Copy to admin?
458                 if ( $params->{admin_email} ) {
459                     C4::Letters::EnqueueLetter(
460                         {
461                             letter                 => $part->{letter},
462                             to_address             => $params->{admin_email},
463                             message_transport_type => 'email',
464                         }
465                       )
466                       or warn
467 "can't enqueue letter $part->{letter} for $params->{admin_email}";
468                 }
469             }
470             else {
471                 my $email =
472                   "-------- Email message --------" . "\n\n" . "To: "
473                   . defined($addressee)               ? $addressee
474                   : defined( $params->{admin_email} ) ? $params->{admin_email}
475                   : '' . "\n"
476                   . "Subject: "
477                   . $part->{letter}->{title} . "\n\n"
478                   . $part->{letter}->{content};
479                 push @emails, $email;
480             }
481         }
482     }
483
484     # Emit to stdout instead of email?
485     if ( !$params->{send_email} ) {
486
487         # The final message is the header + body of this part.
488         my $msg = $header;
489         $msg .= "No database updates have been performed.\n\n"
490           unless ( $params->{execute} );
491
492         # Append email reports to message
493         $msg .= join( "\n\n", @emails );
494         printf $msg;
495     }
496 }
497
498 #### Main Code
499
500 # Compile Stockrotation Report data
501 my $rotas = Koha::StockRotationRotas->search(undef,{ order_by => { '-asc' => 'title' }});
502 my $data  = $rotas->investigate;
503
504 # Perform db updates if requested
505 execute($data) if ($execute);
506
507 # Emit Reports
508 my $out_report = {};
509 $out_report = report_email( $data, $branch ) if $report eq 'email';
510 $out_report = report_full( $data, $branch ) if $report eq 'full';
511 emit(
512     {
513         admin_email => $admin_email,
514         execute     => $execute,
515         report      => $out_report,
516         send_all    => $send_all,
517         send_email  => $send_email,
518     }
519 );
520
521 =head1 AUTHOR
522
523 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
524
525 =cut