overdue_notices.pl various bugs in HEAD 3.2
[koha.git] / misc / cronjobs / overdue_notices.pl
1 #!/usr/bin/perl -w
2
3 # Copyright 2008 Liblime
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 2 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 with
17 # Koha; if not, write to the Free Software Foundation, Inc., 59 Temple Place,
18 # Suite 330, Boston, MA  02111-1307 USA
19
20 use strict;
21 use warnings;
22
23 BEGIN {
24
25     # find Koha's Perl modules
26     # test carefully before changing this
27     use FindBin;
28     eval { require "$FindBin::Bin/../kohalib.pl" };
29 }
30
31 use C4::Context;
32 use C4::Dates qw/format_date/;
33 use C4::Debug;
34 use C4::Letters;
35
36 use Getopt::Long;
37 use Pod::Usage;
38 use Text::CSV_XS;
39
40 =head1 NAME
41
42 overdue_notices.pl - prepare messages to be sent to patrons for overdue items
43
44 =head1 SYNOPSIS
45
46 overdue_notices.pl [ -n ] [ -library <branchcode> ] [ -library <branchcode>...] [ -max <number of days> ] [ -csv [ <filename> ] ] [ -itemscontent <field list> ]
47
48  Options:
49    -help                          brief help message
50    -man                           full documentation
51    -n                             No email will be sent
52    -max          <days>           maximum days overdue to deal with
53    -library      <branchname>     only deal with overdues from this library (repeatable : several libraries can be given)
54    -csv          <filename>       populate CSV file
55    -itemscontent <list of fields> item information in templates
56    -borcat       <categorycode>   category code that must be included
57    -borcatout    <categorycode>   category code that must be excluded
58
59 =head1 OPTIONS
60
61 =over 8
62
63 =item B<-help>
64
65 Print a brief help message and exits.
66
67 =item B<-man>
68
69 Prints the manual page and exits.
70
71 =item B<-v>
72
73 Verbose. Without this flag set, only fatal errors are reported.
74
75 =item B<-n>
76
77 Do not send any email. Overdue notices that would have been sent to
78 the patrons or to the admin are printed to standard out. CSV data (if
79 the -csv flag is set) is written to standard out or to any csv
80 filename given.
81
82 =item B<-max>
83
84 Items older than max days are assumed to be handled somewhere else,
85 probably the F<longoverdues.pl> script. They are therefore ignored by
86 this program. No notices are sent for them, and they are not added to
87 any CSV files. Defaults to 90 to match F<longoverdues.pl>.
88
89 =item B<-library>
90
91 select overdues for one specific library. Use the value in the
92 branches.branchcode table. This option can be repeated in order 
93 to select overdues for a group of libraries.
94
95 =item B<-csv>
96
97 Produces CSV data. if -n (no mail) flag is set, then this CSV data is
98 sent to standard out or to a filename if provided. Otherwise, only
99 overdues that could not be emailed are sent in CSV format to the admin.
100
101 =item B<-itemscontent>
102
103 comma separated list of fields that get substituted into templates in
104 places of the E<lt>E<lt>items.contentE<gt>E<gt> placeholder. This
105 defaults to issuedate,title,barcode,author
106
107 Other possible values come from fields in the biblios, items, and
108 issues tables.
109
110 =item B<-borcat>
111
112 Repetable field, that permit to select only few of patrons categories.
113
114 =item B<-borcatout>
115
116 Repetable field, permis to exclude some patrons categories.
117
118 =item B<-t> | B<--triggered>
119
120 This option causes a notice to be generated if and only if 
121 an item is overdue by the number of days defined in a notice trigger.
122
123 By default, a notice is sent each time the script runs, which is suitable for 
124 less frequent run cron script, but requires syncing notice triggers with 
125 the  cron schedule to ensure proper behavior.
126 Add the --triggered option for daily cron, at the risk of no notice 
127 being generated if the cron fails to run on time.
128
129 =item B<-list-all>
130
131 Default items.content lists only those items that fall in the 
132 range of the currently processing notice.
133 Choose list-all to include all overdue items in the list (limited by B<-max> setting).
134
135 =back
136
137 =head1 DESCRIPTION
138
139 This script is designed to alert patrons and administrators of overdue
140 items.
141
142 =head2 Configuration
143
144 This script pays attention to the overdue notice configuration
145 performed in the "Overdue notice/status triggers" section of the
146 "Tools" area of the staff interface to Koha. There, you can choose
147 which letter templates are sent out after a configurable number of
148 days to patrons of each library. More information about the use of this
149 section of Koha is available in the Koha manual.
150
151 The templates used to craft the emails are defined in the "Tools:
152 Notices" section of the staff interface to Koha.
153
154 =head2 Outgoing emails
155
156 Typically, messages are prepared for each patron with overdue
157 items. Messages for whom there is no email address on file are
158 collected and sent as attachments in a single email to each library
159 administrator, or if that is not set, then to the email address in the
160 C<KohaAdminEmailAddress> system preference.
161
162 These emails are staged in the outgoing message queue, as are messages
163 produced by other features of Koha. This message queue must be
164 processed regularly by the
165 F<misc/cronjobs/process_message_queue.pl> program.
166
167 In the event that the C<-n> flag is passed to this program, no emails
168 are sent. Instead, messages are sent on standard output from this
169 program. They may be redirected to a file if desired.
170
171 =head2 Templates
172
173 Templates can contain variables enclosed in double angle brackets like
174 E<lt>E<lt>thisE<gt>E<gt>. Those variables will be replaced with values
175 specific to the overdue items or relevant patron. Available variables
176 are:
177
178 =over
179
180 =item E<lt>E<lt>bibE<gt>E<gt>
181
182 the name of the library
183
184 =item E<lt>E<lt>items.contentE<gt>E<gt>
185
186 one line for each item, each line containing a tab separated list of
187 title, author, barcode, issuedate
188
189 =item E<lt>E<lt>borrowers.*E<gt>E<gt>
190
191 any field from the borrowers table
192
193 =item E<lt>E<lt>branches.*E<gt>E<gt>
194
195 any field from the branches table
196
197 =back
198
199 =head2 CSV output
200
201 The C<-csv> command line option lets you specify a file to which
202 overdues data should be output in CSV format.
203
204 With the C<-n> flag set, data about all overdues is written to the
205 file. Without that flag, only information about overdues that were
206 unable to be sent directly to the patrons will be written. In other
207 words, this CSV file replaces the data that is typically sent to the
208 administrator email address.
209
210 =head1 USAGE EXAMPLES
211
212 C<overdue_notices.pl> - In this most basic usage, with no command line
213 arguments, all libraries are procesed individually, and notices are
214 prepared for all patrons with overdue items for whom we have email
215 addresses. Messages for those patrons for whom we have no email
216 address are sent in a single attachment to the library administrator's
217 email address, or to the address in the KohaAdminEmailAddress system
218 preference.
219
220 C<overdue_notices.pl -n -csv /tmp/overdues.csv> - sends no email and
221 populates F</tmp/overdues.csv> with information about all overdue
222 items.
223
224 C<overdue_notices.pl -library MAIN max 14> - prepare notices of
225 overdues in the last 2 weeks for the MAIN library.
226
227 =head1 SEE ALSO
228
229 The F<misc/cronjobs/advance_notices.pl> program allows you to send
230 messages to patrons in advance of thier items becoming due, or to
231 alert them of items that have just become due.
232
233 =cut
234
235 # These variables are set by command line options.
236 # They are initially set to default values.
237 my $dbh = C4::Context->dbh();
238 my $help    = 0;
239 my $man     = 0;
240 my $verbose = 0;
241 my $nomail  = 0;
242 my $MAX     = 90;
243 my @branchcodes; # Branch(es) passed as parameter
244 my $csvfilename;
245 my $triggered = 0;
246 my $listall = 0;
247 my $itemscontent = join( ',', qw( issuedate title barcode author ) );
248 my @myborcat;
249 my @myborcatout;
250
251 GetOptions(
252     'help|?'         => \$help,
253     'man'            => \$man,
254     'v'              => \$verbose,
255     'n'              => \$nomail,
256     'max=s'          => \$MAX,
257     'library=s'      => \@branchcodes,
258     'csv:s'          => \$csvfilename,    # this optional argument gets '' if not supplied.
259     'itemscontent=s' => \$itemscontent,
260     'list-all'      => \$listall,
261     't|triggered'             => \$triggered,
262     'borcat=s'      => \@myborcat,
263     'borcatout=s'   => \@myborcatout,
264 ) or pod2usage(2);
265 pod2usage(1) if $help;
266 pod2usage( -verbose => 2 ) if $man;
267
268 if ( defined $csvfilename && $csvfilename =~ /^-/ ) {
269     warn qq(using "$csvfilename" as filename, that seems odd);
270 }
271
272 my @overduebranches    = C4::Overdues::GetBranchcodesWithOverdueRules();        # Branches with overdue rules
273 my @branches;                                                                   # Branches passed as parameter with overdue rules
274 my $branchcount = scalar(@overduebranches);
275
276 my $overduebranch_word = scalar @overduebranches > 1 ? 'branches' : 'branch';
277 my $branchcodes_word = scalar @branchcodes > 1 ? 'branches' : 'branch';
278
279 if ($branchcount) {
280     $verbose and warn "Found $branchcount $overduebranch_word with first message enabled: " . join( ', ', map { "'$_'" } @overduebranches ), "\n";
281 } else {
282     die 'No branches with active overduerules';
283 }
284
285 if (@branchcodes) {
286     $verbose and warn "$branchcodes_word @branchcodes passed on parameter\n";
287     
288     # Getting libraries which have overdue rules
289     my %seen = map { $_ => 1 } @branchcodes;
290     @branches = grep { $seen{$_} } @overduebranches;
291     
292     
293     if (@overduebranches) {
294
295         my $branch_word = scalar @branches > 1 ? 'branches' : 'branch';
296         $verbose and warn "$branch_word @branches have overdue rules\n";
297
298     } else {
299     
300         $verbose and warn "No active overduerules for $branchcodes_word  '@branchcodes'\n";
301         ( scalar grep { '' eq $_ } @branches )
302           or die "No active overduerules for DEFAULT either!";
303         $verbose and warn "Falling back on default rules for @branchcodes\n";
304         @branches = ('');
305     }
306 }
307
308 # these are the fields that will be substituted into <<item.content>>
309 my @item_content_fields = split( /,/, $itemscontent );
310
311 binmode( STDOUT, ":utf8" );
312
313 our $csv;       # the Text::CSV_XS object
314 our $csv_fh;    # the filehandle to the CSV file.
315 if ( defined $csvfilename ) {
316     my $sep_char = C4::Context->preference('delimiter') || ',';
317     $csv = Text::CSV_XS->new( { binary => 1 , sep_char => $sep_char } );
318     if ( $csvfilename eq '' ) {
319         $csv_fh = *STDOUT;
320     } else {
321         open $csv_fh, ">", $csvfilename or die "unable to open $csvfilename: $!";
322     }
323     if ( $csv->combine(qw(name surname address1 address2 zipcode city country email itemcount itemsinfo)) ) {
324         print $csv_fh $csv->string, "\n";
325     } else {
326         $verbose and warn 'combine failed on argument: ' . $csv->error_input;
327     }
328 }
329
330 @branches = @overduebranches unless @branches;
331 foreach my $branchcode (@branches) {
332
333     my $branch_details = C4::Branch::GetBranchDetail($branchcode);
334     my $admin_email_address = $branch_details->{'branchemail'} || C4::Context->preference('KohaAdminEmailAddress');
335     my @output_chunks;    # may be sent to mail or stdout or csv file.
336
337     $verbose and warn sprintf "branchcode : '%s' using %s\n", $branchcode, $admin_email_address;
338
339     my $sth2 = $dbh->prepare( <<'END_SQL' );
340 SELECT biblio.*, items.*, issues.*, TO_DAYS(NOW())-TO_DAYS(date_due) AS days_overdue
341   FROM issues,items,biblio
342   WHERE items.itemnumber=issues.itemnumber
343     AND biblio.biblionumber   = items.biblionumber
344     AND issues.borrowernumber = ?
345     AND TO_DAYS(NOW())-TO_DAYS(date_due) BETWEEN ? and ?
346 END_SQL
347
348     my $query = "SELECT * FROM overduerules WHERE delay1 IS NOT NULL AND branchcode = ? ";
349     $query .= " AND categorycode IN (".join( ',' , ('?') x @myborcat ).") " if (@myborcat);
350     $query .= " AND categorycode NOT IN (".join( ',' , ('?') x @myborcatout ).") " if (@myborcatout);
351     
352     my $rqoverduerules =  $dbh->prepare($query);
353     $rqoverduerules->execute($branchcode, @myborcat, @myborcatout);
354     
355     # We get default rules is there is no rule for this branch
356     if($rqoverduerules->rows == 0){
357         $query = "SELECT * FROM overduerules WHERE delay1 IS NOT NULL AND branchcode = '' ";
358         $query .= " AND categorycode IN (".join( ',' , ('?') x @myborcat ).") " if (@myborcat);
359         $query .= " AND categorycode NOT IN (".join( ',' , ('?') x @myborcatout ).") " if (@myborcatout);
360         
361         $rqoverduerules = $dbh->prepare($query);
362         $rqoverduerules->execute(@myborcat, @myborcatout);
363     }
364
365     # my $outfile = 'overdues_' . ( $mybranch || $branchcode || 'default' );
366     while ( my $overdue_rules = $rqoverduerules->fetchrow_hashref ) {
367       PERIOD: foreach my $i ( 1 .. 3 ) {
368
369             $verbose and warn "branch '$branchcode', pass $i\n";
370             my $mindays = $overdue_rules->{"delay$i"};    # the notice will be sent after mindays days (grace period)
371             my $maxdays = (
372                   $overdue_rules->{ "delay" . ( $i + 1 ) }
373                 ? $overdue_rules->{ "delay" . ( $i + 1 ) }
374                 : ($MAX)
375             );                                            # issues being more than maxdays late are managed somewhere else. (borrower probably suspended)
376
377             if ( !$overdue_rules->{"letter$i"} ) {
378                 $verbose and warn "No letter$i code for branch '$branchcode'";
379                 next PERIOD;
380             }
381
382             # $letter->{'content'} is the text of the mail that is sent.
383             # this text contains fields that are replaced by their value. Those fields must be written between brackets
384             # The following fields are available :
385             # itemcount is interpreted here as the number of items in the overdue range defined by the current notice or all overdues < max if(-list-all).
386             # <date> <itemcount> <firstname> <lastname> <address1> <address2> <address3> <city> <postcode>
387
388             my $borrower_sql = <<'END_SQL';
389 SELECT COUNT(*), issues.borrowernumber, firstname, surname, address, address2, city, zipcode, country, email, MIN(date_due) as longest_issue
390 FROM   issues,borrowers,categories
391 WHERE  issues.borrowernumber=borrowers.borrowernumber
392 AND    borrowers.categorycode=categories.categorycode
393 END_SQL
394             my @borrower_parameters;
395             if ($branchcode) {
396                 $borrower_sql .= ' AND issues.branchcode=? ';
397                 push @borrower_parameters, $branchcode;
398             }
399             if ( $overdue_rules->{categorycode} ) {
400                 $borrower_sql .= ' AND borrowers.categorycode=? ';
401                 push @borrower_parameters, $overdue_rules->{categorycode};
402             }
403             $borrower_sql .= '  AND categories.overduenoticerequired=1
404                                 GROUP BY issues.borrowernumber ';
405             if($triggered) {
406                 $borrower_sql .= ' HAVING TO_DAYS(NOW())-TO_DAYS(longest_issue) = ?';
407                 push @borrower_parameters, $mindays;
408             } else {
409                 $borrower_sql .= ' HAVING TO_DAYS(NOW())-TO_DAYS(longest_issue) BETWEEN ? and ? ' ;
410                 push @borrower_parameters, $mindays, $maxdays;
411             }
412
413             # $sth gets borrower info iff at least one overdue item has triggered the overdue action.
414                 my $sth = $dbh->prepare($borrower_sql);
415             $sth->execute(@borrower_parameters);
416             $verbose and warn $borrower_sql . "\n $branchcode | " . $overdue_rules->{'categorycode'} . "\n ($mindays, $maxdays)\nreturns " . $sth->rows . " rows";
417
418             while( my ( $itemcount, $borrowernumber, $firstname, $lastname, $address1, $address2, $city, $postcode, $email ) = $sth->fetchrow ) {
419                 $verbose and warn "borrower $firstname, $lastname ($borrowernumber) has $itemcount items triggering level $i.";
420     
421                 my $letter = C4::Letters::getletter( 'circulation', $overdue_rules->{"letter$i"} );
422                 unless ($letter) {
423                     $verbose and warn "Message '$overdue_rules->{letter$i}' content not found";
424     
425                     # might as well skip while PERIOD, no other borrowers are going to work.
426                     # FIXME : Does this mean a letter must be defined in order to trigger a debar ?
427                     next PERIOD;
428                 }
429     
430                 if ( $overdue_rules->{"debarred$i"} ) {
431     
432                     #action taken is debarring
433                     C4::Members::DebarMember($borrowernumber);
434                     $verbose and warn "debarring $borrowernumber $firstname $lastname\n";
435                 }
436                 $sth2->execute( ($listall) ? ( $borrowernumber , 1 , $MAX ) : ( $borrowernumber, $mindays, $maxdays ) );
437                 my $itemcount = 0;
438                 my $titles = "";
439                 while ( my $item_info = $sth2->fetchrow_hashref() ) {
440                     my @item_info = map { $_ =~ /^date|date$/ ? format_date( $item_info->{$_} ) : $item_info->{$_} || '' } @item_content_fields;
441                     $titles .= join("\t", @item_info) . "\n";
442                     $itemcount++;
443                 }
444                 $sth2->finish;
445     
446                 $letter = parse_letter(
447                     {   letter         => $letter,
448                         borrowernumber => $borrowernumber,
449                         branchcode     => $branchcode,
450                         substitute     => {
451                             bib             => $branch_details->{'branchname'},
452                             'items.content' => $titles
453                         }
454                     }
455                 );
456     
457                 my @misses = grep { /./ } map { /^([^>]*)[>]+/; ( $1 || '' ); } split /\</, $letter->{'content'};
458                 if (@misses) {
459                     $verbose and warn "The following terms were not matched and replaced: \n\t" . join "\n\t", @misses;
460                 }
461                 $letter->{'content'} =~ s/\<[^<>]*?\>//g;    # Now that we've warned about them, remove them.
462                 $letter->{'content'} =~ s/\<[^<>]*?\>//g;    # 2nd pass for the double nesting.
463     
464                 if ($nomail) {
465     
466                     push @output_chunks,
467                       prepare_letter_for_printing(
468                         {   letter         => $letter,
469                             borrowernumber => $borrowernumber,
470                             firstname      => $firstname,
471                             lastname       => $lastname,
472                             address1       => $address1,
473                             address2       => $address2,
474                             city           => $city,
475                             postcode       => $postcode,
476                             email          => $email,
477                             itemcount      => $itemcount,
478                             titles         => $titles,
479                             outputformat   => defined $csvfilename ? 'csv' : '',
480                         }
481                       );
482                 } else {
483                     if ($email) {
484                         C4::Letters::EnqueueLetter(
485                             {   letter                 => $letter,
486                                 borrowernumber         => $borrowernumber,
487                                 message_transport_type => 'email',
488                                 from_address           => $admin_email_address,
489                             }
490                         );
491                     } else {
492     
493                         # If we don't have an email address for this patron, send it to the admin to deal with.
494                         push @output_chunks,
495                           prepare_letter_for_printing(
496                             {   letter         => $letter,
497                                 borrowernumber => $borrowernumber,
498                                 firstname      => $firstname,
499                                 lastname       => $lastname,
500                                 address1       => $address1,
501                                 address2       => $address2,
502                                 city           => $city,
503                                 postcode       => $postcode,
504                                 email          => $email,
505                                 itemcount      => $itemcount,
506                                 titles         => $titles,
507                                 outputformat   => defined $csvfilename ? 'csv' : '',
508                             }
509                           );
510                     }
511                 }
512             }
513             $sth->finish;
514         }
515     }
516
517     if (@output_chunks) {
518         if ($nomail) {
519             if ( defined $csvfilename ) {
520                 print $csv_fh @output_chunks;
521             } else {
522                 local $, = "\f";    # pagebreak
523                 print @output_chunks;
524             }
525         } else {
526             my $attachment = {
527                 filename => defined $csvfilename ? 'attachment.csv' : 'attachment.txt',
528                 type => 'text/plain',
529                 content => join( "\n", @output_chunks )
530             };
531
532             my $letter = {
533                 title   => 'Overdue Notices',
534                 content => 'These messages were not sent directly to the patrons.',
535             };
536             C4::Letters::EnqueueLetter(
537                 {   letter                 => $letter,
538                     borrowernumber         => undef,
539                     message_transport_type => 'email',
540                     attachments            => [$attachment],
541                     to_address             => $admin_email_address,
542                 }
543             );
544         }
545     }
546
547 }
548 if ($csvfilename) {
549
550     # note that we're not testing on $csv_fh to prevent closing
551     # STDOUT.
552     close $csv_fh;
553 }
554
555 =head1 INTERNAL METHODS
556
557 These methods are internal to the operation of overdue_notices.pl.
558
559 =head2 parse_letter
560
561 parses the letter template, replacing the placeholders with data
562 specific to this patron, biblio, or item
563
564 named parameters:
565   letter - required hashref
566   borrowernumber - required integer
567   substitute - optional hashref of other key/value pairs that should
568     be substituted in the letter content
569
570 returns the C<letter> hashref, with the content updated to reflect the
571 substituted keys and values.
572
573
574 =cut
575
576 sub parse_letter {
577     my $params = shift;
578     foreach my $required (qw( letter borrowernumber )) {
579         return unless exists $params->{$required};
580     }
581
582     if ( $params->{'substitute'} ) {
583         while ( my ( $key, $replacedby ) = each %{ $params->{'substitute'} } ) {
584             my $replacefield = "<<$key>>";
585
586             $params->{'letter'}->{title}   =~ s/$replacefield/$replacedby/g;
587             $params->{'letter'}->{content} =~ s/$replacefield/$replacedby/g;
588         }
589     }
590
591     C4::Letters::parseletter( $params->{'letter'}, 'borrowers', $params->{'borrowernumber'} );
592
593     if ( $params->{'branchcode'} ) {
594         C4::Letters::parseletter( $params->{'letter'}, 'branches', $params->{'branchcode'} );
595     }
596
597     if ( $params->{'biblionumber'} ) {
598         C4::Letters::parseletter( $params->{'letter'}, 'biblio',      $params->{'biblionumber'} );
599         C4::Letters::parseletter( $params->{'letter'}, 'biblioitems', $params->{'biblionumber'} );
600     }
601
602     return $params->{'letter'};
603 }
604
605 =head2 prepare_letter_for_printing
606
607 returns a string of text appropriate for printing in the event that an
608 overdue notice will not be sent to the patron's email
609 address. Depending on the desired output format, this may be a CSV
610 string, or a human-readable representation of the notice.
611
612 required parameters:
613   letter
614   borrowernumber
615
616 optional parameters:
617   outputformat
618
619 =cut
620
621 sub prepare_letter_for_printing {
622     my $params = shift;
623
624     return unless ref $params eq 'HASH';
625
626     foreach my $required_parameter (qw( letter borrowernumber )) {
627         return unless defined $params->{$required_parameter};
628     }
629
630     my $return;
631     if ( exists $params->{'outputformat'} && $params->{'outputformat'} eq 'csv' ) {
632         if ($csv->combine(
633                 $params->{'firstname'}, $params->{'lastname'}, $params->{'address1'},  $params->{'address2'}, $params->{'postcode'},
634                 $params->{'city'},      $params->{'email'},    $params->{'itemcount'}, $params->{'titles'}
635             )
636           ) {
637             return $csv->string, "\n";
638         } else {
639             $verbose and warn 'combine failed on argument: ' . $csv->error_input;
640         }
641     } else {
642         $return .= "$params->{'letter'}->{'content'}\n";
643
644         # $return .= Data::Dumper->Dump( [ $params->{'borrowernumber'}, $params->{'letter'} ], [qw( borrowernumber letter )] );
645     }
646     return $return;
647 }
648