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